Skip to content

Commit 6b63a05

Browse files
committed
feat: add orders status transitions (finite state machine)
1 parent f8655d1 commit 6b63a05

7 files changed

Lines changed: 225 additions & 2 deletions

File tree

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help run stop test test-unit test-integration clean
1+
.PHONY: help run stop test test-unit test-integration clean migrations
22

33
help:
44
@echo "Available commands:"
@@ -8,6 +8,7 @@ help:
88
@echo " test-unit - Run unit tests"
99
@echo " test-integration - Run integration tests with fresh database"
1010
@echo " clean - Clean up Docker containers and volumes"
11+
@echo " migrations msg - Generate alembic migration via Docker (e.g. make migrations msg='add cancelled status')"
1112

1213
run:
1314
docker compose up --build -d
@@ -34,6 +35,10 @@ test-integration:
3435
docker compose run --rm app python -m pytest tests/integration/ -v
3536
$(MAKE) clean
3637

38+
migrations:
39+
@if [ -z "$(msg)" ]; then echo "Usage: make migrations msg='your message'"; exit 1; fi
40+
docker compose run --rm app alembic revision --autogenerate -m "$(msg)"
41+
3742
clean:
3843
$(MAKE) stop
3944
@echo "Cleaning all data..."
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""add CANCELLED and FULFILLED to OrderStatus enum
2+
3+
Revision ID: 814107d68bb6
4+
Revises: 084d9bd91fca
5+
Create Date: 2026-04-27 05:13:11.551687
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '814107d68bb6'
16+
down_revision: Union[str, Sequence[str], None] = '084d9bd91fca'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# Rename old type, create new type with all values, reassign column
24+
op.execute("ALTER TYPE orderstatus RENAME TO orderstatus_old")
25+
op.execute(
26+
"CREATE TYPE orderstatus AS ENUM "
27+
"('RECEIVED', 'PROCESSING', 'FULFILLED', 'SHIPPED', 'DELIVERED', 'CANCELLED')"
28+
)
29+
op.execute(
30+
"ALTER TABLE orders ALTER COLUMN status TYPE orderstatus "
31+
"USING status::text::orderstatus"
32+
)
33+
op.execute("DROP TYPE orderstatus_old")
34+
35+
36+
def downgrade() -> None:
37+
"""Downgrade schema."""
38+
op.execute("ALTER TYPE orderstatus RENAME TO orderstatus_old")
39+
op.execute(
40+
"CREATE TYPE orderstatus AS ENUM "
41+
"('RECEIVED', 'PROCESSING', 'SHIPPED', 'DELIVERED')"
42+
)
43+
op.execute(
44+
"ALTER TABLE orders ALTER COLUMN status TYPE orderstatus "
45+
"USING status::text::orderstatus"
46+
)
47+
op.execute("DROP TYPE orderstatus_old")

app/modules/orders/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
class OrderStatus(str, enum.Enum):
77
RECEIVED = "received"
88
PROCESSING = "processing"
9+
FULFILLED = "fulfilled"
910
SHIPPED = "shipped"
1011
DELIVERED = "delivered"
12+
CANCELLED = "cancelled"
1113

1214

1315
class Order(Base):

app/modules/orders/routes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from app.db.session import get_db
55
from . import service, schemas, repository
6+
from .service import InvalidOrderTransition
67
from app.core.auth import get_current_user
78

89
router = APIRouter()
@@ -53,6 +54,8 @@ def update_order(
5354
return service.update_order(repo, order_id, **updates)
5455
except ValueError:
5556
raise HTTPException(status_code=404, detail="Order not found")
57+
except InvalidOrderTransition as e:
58+
raise HTTPException(status_code=400, detail=str(e))
5659

5760

5861
@router.delete("/{order_id}", status_code=204)

app/modules/orders/service.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
1-
from . import repository
1+
from . import repository, models
2+
3+
4+
class InvalidOrderTransition(Exception):
5+
"""Raised when a requested status transition is not allowed."""
6+
pass
7+
8+
9+
class OrderStateMachine:
10+
"""Finite state machine for order status transitions."""
11+
12+
TRANSITIONS = {
13+
models.OrderStatus.RECEIVED: [
14+
models.OrderStatus.PROCESSING,
15+
models.OrderStatus.CANCELLED,
16+
],
17+
models.OrderStatus.PROCESSING: [
18+
models.OrderStatus.FULFILLED,
19+
models.OrderStatus.CANCELLED,
20+
],
21+
models.OrderStatus.FULFILLED: [
22+
models.OrderStatus.SHIPPED,
23+
models.OrderStatus.CANCELLED,
24+
],
25+
models.OrderStatus.SHIPPED: [
26+
models.OrderStatus.DELIVERED
27+
],
28+
models.OrderStatus.DELIVERED: [],
29+
models.OrderStatus.CANCELLED: [],
30+
}
31+
32+
@classmethod
33+
def validate_transition(cls, current: models.OrderStatus, target: models.OrderStatus) -> None:
34+
allowed = cls.TRANSITIONS.get(current)
35+
if allowed is None:
36+
raise InvalidOrderTransition(f"Unknown status: {current}")
37+
if target not in allowed:
38+
raise InvalidOrderTransition(
39+
f"Cannot transition from {current} to {target}"
40+
)
241

342

443
def create_order(repo: repository.OrderRepository, item: str):
@@ -21,6 +60,10 @@ def update_order(repo: repository.OrderRepository, order_id: int, **kwargs):
2160
order = repo.get_order(order_id)
2261
if not order:
2362
raise ValueError("Order not found")
63+
64+
if "status" in kwargs and kwargs["status"] is not None:
65+
OrderStateMachine.validate_transition(order.status, kwargs["status"])
66+
2467
for key, value in kwargs.items():
2568
if value is not None:
2669
setattr(order, key, value)

tests/integration/test_orders.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,53 @@ def test_delete_order_not_found(client):
135135
def test_orders_without_auth_returns_401(client):
136136
response = client.get("/orders/")
137137
assert response.status_code == 401
138+
139+
140+
def test_update_order_cancel_from_received(client):
141+
token = _login(client)
142+
created = client.post("/orders/", json={"item": "cancel_me"}, headers={
143+
"Authorization": f"Bearer {token}"
144+
}).json()
145+
order_id = created["id"]
146+
assert created["status"] == "received"
147+
148+
response = client.put(f"/orders/{order_id}", json={"status": "cancelled"}, headers={
149+
"Authorization": f"Bearer {token}"
150+
})
151+
assert response.status_code == 200
152+
data = response.json()
153+
assert data["status"] == "cancelled"
154+
155+
get_response = client.get(f"/orders/{order_id}", headers={
156+
"Authorization": f"Bearer {token}"
157+
})
158+
assert get_response.status_code == 200
159+
assert get_response.json()["status"] == "cancelled"
160+
161+
162+
def test_update_order_invalid_transition(client):
163+
token = _login(client)
164+
created = client.post("/orders/", json={"item": "terminal"}, headers={
165+
"Authorization": f"Bearer {token}"
166+
}).json()
167+
order_id = created["id"]
168+
169+
# Advance through valid transitions to DELIVERED
170+
for next_status in ["processing", "fulfilled", "shipped", "delivered"]:
171+
response = client.put(f"/orders/{order_id}", json={"status": next_status}, headers={
172+
"Authorization": f"Bearer {token}"
173+
})
174+
assert response.status_code == 200
175+
176+
# DELIVERED -> PROCESSING should fail
177+
response = client.put(f"/orders/{order_id}", json={"status": "processing"}, headers={
178+
"Authorization": f"Bearer {token}"
179+
})
180+
assert response.status_code == 400
181+
assert "Cannot transition" in response.json()["detail"]
182+
183+
# Order should still be DELIVERED
184+
get_response = client.get(f"/orders/{order_id}", headers={
185+
"Authorization": f"Bearer {token}"
186+
})
187+
assert get_response.json()["status"] == "delivered"

tests/unit/test_orders.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from unittest.mock import MagicMock
2+
import pytest
23

34
from app.modules.orders import service, repository, models
5+
from app.modules.orders.service import OrderStateMachine, InvalidOrderTransition
46

57

68
def _make_mock_repo(order=None):
@@ -91,3 +93,74 @@ def test_delete_order_not_found():
9193
assert False, "Should have raised ValueError"
9294
except ValueError as e:
9395
assert str(e) == "Order not found"
96+
97+
98+
# ── FSM Unit Tests ──────────────────────────────────────────────────────────
99+
100+
VALID_TRANSITIONS = [
101+
(models.OrderStatus.RECEIVED, models.OrderStatus.PROCESSING),
102+
(models.OrderStatus.RECEIVED, models.OrderStatus.CANCELLED),
103+
(models.OrderStatus.PROCESSING, models.OrderStatus.FULFILLED),
104+
(models.OrderStatus.PROCESSING, models.OrderStatus.CANCELLED),
105+
(models.OrderStatus.FULFILLED, models.OrderStatus.SHIPPED),
106+
(models.OrderStatus.FULFILLED, models.OrderStatus.CANCELLED),
107+
(models.OrderStatus.SHIPPED, models.OrderStatus.DELIVERED),
108+
]
109+
110+
INVALID_TRANSITIONS = [
111+
(models.OrderStatus.RECEIVED, models.OrderStatus.FULFILLED),
112+
(models.OrderStatus.RECEIVED, models.OrderStatus.SHIPPED),
113+
(models.OrderStatus.RECEIVED, models.OrderStatus.DELIVERED),
114+
(models.OrderStatus.PROCESSING, models.OrderStatus.SHIPPED),
115+
(models.OrderStatus.PROCESSING, models.OrderStatus.DELIVERED),
116+
(models.OrderStatus.FULFILLED, models.OrderStatus.DELIVERED),
117+
(models.OrderStatus.FULFILLED, models.OrderStatus.RECEIVED),
118+
(models.OrderStatus.SHIPPED, models.OrderStatus.PROCESSING),
119+
(models.OrderStatus.SHIPPED, models.OrderStatus.CANCELLED),
120+
(models.OrderStatus.DELIVERED, models.OrderStatus.PROCESSING),
121+
(models.OrderStatus.DELIVERED, models.OrderStatus.DELIVERED),
122+
(models.OrderStatus.CANCELLED, models.OrderStatus.PROCESSING),
123+
(models.OrderStatus.CANCELLED, models.OrderStatus.CANCELLED),
124+
]
125+
126+
127+
@pytest.mark.parametrize("current, target", VALID_TRANSITIONS)
128+
def test_fsm_valid_transitions(current, target):
129+
OrderStateMachine.validate_transition(current, target)
130+
131+
132+
@pytest.mark.parametrize("current, target", INVALID_TRANSITIONS)
133+
def test_fsm_invalid_transitions(current, target):
134+
with pytest.raises(InvalidOrderTransition):
135+
OrderStateMachine.validate_transition(current, target)
136+
137+
138+
def test_fsm_unknown_current_status():
139+
with pytest.raises(InvalidOrderTransition):
140+
OrderStateMachine.validate_transition("unknown", models.OrderStatus.PROCESSING)
141+
142+
143+
def test_fsm_unknown_target_status():
144+
with pytest.raises(InvalidOrderTransition):
145+
OrderStateMachine.validate_transition(models.OrderStatus.RECEIVED, "unknown")
146+
147+
148+
# ── Service Layer FSM Integration Tests ─────────────────────────────────────
149+
150+
151+
def test_update_order_invalid_status_transition():
152+
order = _make_order()
153+
repo = _make_mock_repo(order=order)
154+
with pytest.raises(InvalidOrderTransition):
155+
service.update_order(repo, 1, status="delivered")
156+
repo._db.commit.assert_not_called()
157+
repo._db.refresh.assert_not_called()
158+
159+
160+
def test_update_order_valid_status_transition():
161+
order = _make_order()
162+
repo = _make_mock_repo(order=order)
163+
result = service.update_order(repo, 1, status="processing")
164+
assert result.status == "processing"
165+
repo._db.commit.assert_called_once()
166+
repo._db.refresh.assert_called_once_with(order)

0 commit comments

Comments
 (0)