Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ hello-tasks/backend/api
# Temporary directories produced by `pytest`.
__pycache__/
.pytest_cache/

# Recordings of browser scenarios, made by running the tests.
*.recordings/
2 changes: 1 addition & 1 deletion .tests/all_pytests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function runPyTest () {

# Install the `reboot` package from the specified path explicitly, over-
# writing the version from `pyproject.toml`.
uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE"
uv add --no-sync "reboot[dev] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE"

# Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried
# over from a pre-baked image, or copied between containers/host
Expand Down
3 changes: 2 additions & 1 deletion api/bank/v1/bank.proto
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import "bank/v1/errors.proto";
service BankMethods {
rpc SignUp(SignUpRequest) returns (SignUpResponse) {
option (rbt.v1alpha1.method).transaction = {
exclusive: {},
};
}

rpc Transfer(TransferRequest) returns (TransferResponse) {
option (rbt.v1alpha1.method) = {
transaction: {},
transaction: { shared: {} },
errors: [ "OverdraftError" ],
};
}
Expand Down
24 changes: 24 additions & 0 deletions bank/backend/tests/account.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Feature: Accounts

Background:
Given the application is up
And "anonymous" is an unauthenticated user

Scenario: Depositing and withdrawing move the balance
Given "anonymous" creates an `Account` via `open` with `customer_name="Alice"`
And the resulting state id is saved as "account_id"
Then as "anonymous", `balance` on the `Account` for "<account_id>" has `balance=0`
When "anonymous" does a `deposit` with `amount=100` on `Account` of "<account_id>"
Then as "anonymous", `balance` on the `Account` for "<account_id>" has `balance=100`
When "anonymous" does a `withdraw` with `amount=60` on `Account` of "<account_id>"
Then as "anonymous", `balance` on the `Account` for "<account_id>" has `balance=40`
When "anonymous" attempts a `withdraw` with `amount=65` on `Account` of "<account_id>"
Then the attempt aborts with `OverdraftError` with `amount=25`
And as "anonymous", `balance` on the `Account` for "<account_id>" has `balance=40`

Scenario: Opening sends a welcome email
Given "anonymous" creates an `Account` via `open` with `customer_name="Bob"`
And the resulting state id is saved as "account_id"
And the resulting `welcome_email_task_id` is saved as "welcome_email_task_id"
Then "anonymous" awaits the `welcome_email` task "<welcome_email_task_id>" on `Account` within 30 seconds
And the welcome email was sent
108 changes: 20 additions & 88 deletions bank/backend/tests/account_servicer_test.py
Original file line number Diff line number Diff line change
@@ -1,99 +1,31 @@
import unittest
"""The account's tests: the Gherkin scenarios in `account.feature`."""

import pytest
from account_servicer import AccountServicer
from bank.v1.account_rbt import Account, BalanceResponse
from bank.v1.errors_pb2 import OverdraftError
from reboot.aio.applications import Application
from reboot.aio.tests import Reboot
from reboot.bdd import scenarios, then
from typing import Iterator
from unittest import mock


def report_error_to_user(error_message: str) -> None:
# This is a dummy function for use in documentation code snippets.
pass


class TestAccount(unittest.IsolatedAsyncioTestCase):

async def asyncSetUp(self) -> None:
self.rbt = Reboot()
await self.rbt.start()

async def asyncTearDown(self) -> None:
await self.rbt.stop()

async def test_basics(self) -> None:
await self.rbt.up(Application(servicers=[AccountServicer]))
context = self.rbt.create_external_context(name=f"test-{self.id()}")

# Create the state machine by calling its constructor. The fact that the
# state machine _has_ a constructor means that this step is required
# before other methods can be called on it.
account, _ = await Account.open(context, customer_name="Alice")

# We can now call methods on the state machine. It should have a balance
# of 0.
response: BalanceResponse = await account.balance(context)
self.assertEqual(response.balance, 0)

# When we deposit money, the balance should go up.
await account.deposit(context, amount=100)
response = await account.balance(context)
self.assertEqual(response.balance, 100)

# When we withdraw money, the balance should go down.
await account.withdraw(context, amount=60)
response = await account.balance(context)
self.assertEqual(response.balance, 40)

# When we withdraw too much money, we should get an error.
# Use a helper function here to get a code snippet for use in docs.
async def withdraw():
try:
await account.withdraw(context, amount=65)
except Account.WithdrawAborted as aborted:
match aborted.error:
case OverdraftError(amount=amount):
report_error_to_user(
'Your withdrawal could not be processed due to '
'insufficient funds. Your account balance is less '
f'than the requested amount by {amount} dollars.'
)
raise
@pytest.fixture
def application() -> Application:
return Application(servicers=[AccountServicer])

with self.assertRaises(Account.WithdrawAborted) as aborted:
await withdraw()

self.assertTrue(isinstance(aborted.exception.error, OverdraftError))
self.assertEqual(aborted.exception.error.amount, 25)
# ... and the balance shouldn't have changed.
response = await account.balance(context)
self.assertEqual(response.balance, 40)
# The welcome email goes through `send_email`, mocked so scenarios
# can observe it.
@pytest.fixture(autouse=True)
def send_email() -> Iterator[mock.AsyncMock]:
with mock.patch('account_servicer.send_email') as mocked:
yield mocked

@mock.patch("account_servicer.send_email")
async def test_send_welcome_email(self, mock_send_email) -> None:
await self.rbt.up(
Application(servicers=[AccountServicer]),
)
context = self.rbt.create_external_context(name=f"test-{self.id()}")

# When we open an account, we expect the user to receive a welcome
# email.
account, open_response = await Account.open(
context,
customer_name="Alice",
)
@then('the welcome email was sent')
def _the_welcome_email_was_sent(send_email: mock.AsyncMock) -> None:
# Reboot re-runs methods twice in development mode to validate
# that they are idempotent, so the email sends twice.
assert send_email.call_count == 2

welcome_email_task_id = open_response.welcome_email_task_id

# Wait for the email task to run.
response = await Account.WelcomeEmailTask.retrieve(
context,
task_id=welcome_email_task_id,
)
# We are only capturing the response for docs purposes, and
# need to explicitly delete it to avoid linting errors.
del response
# We can expect two attempts to send the email, because Reboot always
# re-runs methods twice in development mode in order to validate that
# calls are idempotent.
self.assertEqual(mock_send_email.call_count, 2)
scenarios('account.feature')
23 changes: 23 additions & 0 deletions bank/backend/tests/bank.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Feature: Bank

Background:
Given the application is up
And "anonymous" is an unauthenticated user

Scenario: Signing up opens an account
When "anonymous" does a `sign_up` with `customer_name="Alice"` on `Bank` of "my-bank"
And the resulting `account_id` is saved as "alice_account_id"
Then as "anonymous", `balance` on the `Account` for "<alice_account_id>" has `balance=0`

Scenario: Transfers move money between accounts
Given "anonymous" does a `sign_up` with `customer_name="Alice"` on `Bank` of "my-bank"
And the resulting `account_id` is saved as "alice_account_id"
And "anonymous" does a `sign_up` with `customer_name="Bob"` on `Bank` of "my-bank"
And the resulting `account_id` is saved as "bob_account_id"
When "anonymous" does a `deposit` with `amount=100` on `Account` of "<alice_account_id>"
Then as "anonymous", `balance` on the `Account` for "<alice_account_id>" has `balance=100`
When "anonymous" does a `transfer` with `from_account_id=<alice_account_id>` and `to_account_id=<bob_account_id>` and `amount=40` on `Bank` of "my-bank"
Then as "anonymous", `balance` on the `Account` for "<alice_account_id>" has `balance=60`
And as "anonymous", `balance` on the `Account` for "<bob_account_id>" has `balance=40`
When "anonymous" attempts a `transfer` with `from_account_id=<bob_account_id>` and `to_account_id=<alice_account_id>` and `amount=50` on `Bank` of "my-bank"
Then the attempt aborts with `OverdraftError` with `amount=10`
84 changes: 8 additions & 76 deletions bank/backend/tests/bank_servicer_test.py
Original file line number Diff line number Diff line change
@@ -1,83 +1,15 @@
import unittest
"""The bank's tests: the Gherkin scenarios in `bank.feature`."""

import pytest
from account_servicer import AccountServicer
from bank.v1.account_rbt import Account, BalanceResponse
from bank.v1.bank_rbt import Bank, SignUpResponse
from bank.v1.errors_pb2 import OverdraftError
from bank_servicer import BankServicer
from reboot.aio.applications import Application
from reboot.aio.tests import Reboot


class TestAccount(unittest.IsolatedAsyncioTestCase):

async def asyncSetUp(self) -> None:
self.rbt = Reboot()
await self.rbt.start()

async def asyncTearDown(self) -> None:
await self.rbt.stop()

async def test_signup(self) -> None:
await self.rbt.up(
Application(servicers=[BankServicer, AccountServicer])
)
context = self.rbt.create_external_context(name=f"test-{self.id()}")
bank = Bank.ref("my-bank")

# The Bank state machine doesn't have a constructor, so we can simply
# start calling methods on it.
response: SignUpResponse = await bank.sign_up(
context,
customer_name="Alice",
)

# SignUp will have created an Account we can call.
account = Account.ref(response.account_id)
response = await account.balance(context)
self.assertEqual(response.balance, 0)

async def test_transfer(self):
await self.rbt.up(
Application(servicers=[BankServicer, AccountServicer])
)
context = self.rbt.create_external_context(name=f"test-{self.id()}")
bank = Bank.ref("my-bank")
from reboot.bdd import scenarios

alice: SignUpResponse = await bank.sign_up(
context,
customer_name="Alice",
)
alice_account = Account.ref(alice.account_id)
bob: SignUpResponse = await bank.sign_up(
context,
customer_name="Bob",
)
bob_account = Account.ref(bob.account_id)

# Alice deposits some money.
await alice_account.deposit(context, amount=100)
response: BalanceResponse = await alice_account.balance(context)
self.assertEqual(response.balance, 100)
@pytest.fixture
def application() -> Application:
return Application(servicers=[BankServicer, AccountServicer])

# Alice transfers some money to Bob.
await bank.transfer(
context,
from_account_id=alice.account_id,
to_account_id=bob.account_id,
amount=40,
)
response = await alice_account.balance(context)
self.assertEqual(response.balance, 60)
response = await bob_account.balance(context)
self.assertEqual(response.balance, 40)

# Bob tries to transfer too much money back to Alice.
with self.assertRaises(Bank.TransferAborted) as aborted:
await bank.transfer(
context,
from_account_id=bob.account_id,
to_account_id=alice.account_id,
amount=50,
)
self.assertTrue(isinstance(aborted.exception.error, OverdraftError))
self.assertEqual(aborted.exception.error.amount, 10)
scenarios('bank.feature')
11 changes: 11 additions & 0 deletions hello-constructors/backend/tests/hello.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Feature: Hello with a factory

Background:
Given the application is up
And "anonymous" is an unauthenticated user

Scenario: Messages record from creation onward
Given "anonymous" creates a `Hello` via `create` with `initial_message="first message"`
And the resulting state id is saved as "hello_id"
When "anonymous" does a `send` with `message="second message"` on `Hello` of "<hello_id>"
Then as "anonymous", `messages` on the `Hello` for "<hello_id>" has `messages=["first message", "second message"]`
38 changes: 8 additions & 30 deletions hello-constructors/backend/tests/hello_servicer_test.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,14 @@
import unittest
from hello_constructors.v1.hello_rbt import Hello
"""Hello's tests: the Gherkin scenarios in `hello.feature`."""

import pytest
from hello_servicer import HelloServicer
from reboot.aio.applications import Application
from reboot.aio.tests import Reboot


class TestHello(unittest.IsolatedAsyncioTestCase):

async def asyncSetUp(self) -> None:
self.rbt = Reboot()
await self.rbt.start()

async def asyncTearDown(self) -> None:
await self.rbt.stop()

async def test_hello_constructors(self) -> None:
await self.rbt.up(Application(servicers=[HelloServicer]))
from reboot.bdd import scenarios

context = self.rbt.create_external_context(name=f"test-{self.id()}")

# Create the state machine by calling its constructor. The fact that the
# state machine _has_ a constructor means that this step is required
# before other methods can be called on it.
hello, _ = await Hello.create(context, initial_message="first message")
@pytest.fixture
def application() -> Application:
return Application(servicers=[HelloServicer])

# Send another message.
await hello.send(context, message="second message")

messages_response = await hello.messages(context)
self.assertEqual(
messages_response.messages, [
"first message",
"second message",
]
)
scenarios('hello.feature')
15 changes: 15 additions & 0 deletions hello-tasks/backend/tests/hello.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Feature: Hello with tasks

Background:
Given the application is up
And "anonymous" is an unauthenticated user

Scenario: Sent messages get a warning and then erase
When "anonymous" does a `send` with `message="Hello, World!"` on `Hello` of "testing-hello"
And the resulting `task_id` is saved as "warning_task_id"
# A completed task's response is the result, so the erase task's
# ID saves from it the way any response property does.
When "anonymous" awaits the `warning` task "<warning_task_id>" on `Hello` within 30 seconds
And the resulting `task_id` is saved as "erase_task_id"
And "anonymous" awaits the `erase` task "<erase_task_id>" on `Hello` within 30 seconds
Then as "anonymous", `messages` on the `Hello` for "testing-hello" has `messages` of length `1` and `messages[0]="Number of messages erased so far: 1"`
Loading
Loading