Skip to content

Commit cf3b501

Browse files
authored
πŸ§ͺ νŒ¨ν‚€μ§€λ³„ μœ λ‹› ν…ŒμŠ€νŠΈ μž‘μ„± (#45)
* πŸ§ͺ νŒ¨ν‚€μ§€λ³„ μœ λ‹› ν…ŒμŠ€νŠΈ μž‘μ„± * πŸ› hook μ‚¬μ΄μ—λŠ” unit testκ°€ λŒλ„λ‘ μˆ˜μ • (톡합 ν…ŒμŠ€νŠΈ X)
1 parent 0112537 commit cf3b501

8 files changed

Lines changed: 1331 additions & 8 deletions

β€Ž.claude/settings.jsonβ€Ž

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@
22
"hooks": {
33
"Stop": [
44
{
5-
"hooks": [
6-
{
7-
"type": "command",
8-
"command": "make test && make check",
9-
"timeout": 120,
10-
"statusMessage": "Running make test && make check..."
11-
}
12-
]
5+
"type": "command",
6+
"command": "(uv run pytest tests/unit/ && make check) || true",
7+
"timeout": 120,
8+
"statusMessage": "Running tests and checks..."
139
}
1410
]
1511
}

β€Žtests/unit/__init__.pyβ€Ž

Whitespace-only changes.
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
"""Unit tests for app/services/application.py β€” AsyncSession is mocked."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
from uuid import UUID, uuid4
5+
6+
import pytest
7+
8+
from app.core.exceptions import (
9+
APPLICATION_001,
10+
APPLICATION_002,
11+
APPLICATION_003_APPLY,
12+
APPLICATION_003_SUBMIT,
13+
EVENT_001,
14+
GEN_003_CLOSED,
15+
GEN_003_STATUS,
16+
GEN_005,
17+
AppException,
18+
)
19+
from app.schemas.application import ApplicationCreateReq, ReviewSubmissionReq
20+
from app.services.application import (
21+
cancel_application,
22+
create_application,
23+
submit_review,
24+
)
25+
26+
27+
def _mock_event(
28+
*, is_active: bool = True, contract_address: str | None = None
29+
) -> MagicMock:
30+
event = MagicMock()
31+
event.id = uuid4()
32+
event.store_id = uuid4()
33+
event.title = "Test Event"
34+
event.condition = "Post a photo"
35+
event.reward = int(0.001 * 10**18)
36+
event.is_active = is_active
37+
event.contract_address = contract_address
38+
return event
39+
40+
41+
def _mock_application(
42+
*, status: str = "PENDING", reviewer_id: UUID | None = None
43+
) -> MagicMock:
44+
app = MagicMock()
45+
app.id = uuid4()
46+
app.reviewer_id = reviewer_id or uuid4()
47+
app.event_id = uuid4()
48+
app.status = status
49+
return app
50+
51+
52+
_VALID_WALLET = "0xAbCdEf1234567890AbCdEf1234567890AbCdEf12"
53+
54+
55+
@pytest.mark.asyncio
56+
class TestCancelApplication:
57+
async def test_raises_application_002_when_not_found(self) -> None:
58+
db = AsyncMock()
59+
db.scalar.return_value = None
60+
with pytest.raises(AppException) as exc:
61+
await cancel_application(db, str(uuid4()), str(uuid4()))
62+
assert exc.value.code == APPLICATION_002.code
63+
assert exc.value.status_code == 404
64+
65+
async def test_raises_application_001_when_requester_is_not_reviewer(self) -> None:
66+
reviewer_id = uuid4()
67+
other_id = uuid4()
68+
app = _mock_application(reviewer_id=reviewer_id)
69+
db = AsyncMock()
70+
db.scalar.return_value = app
71+
with pytest.raises(AppException) as exc:
72+
await cancel_application(db, str(app.id), str(other_id))
73+
assert exc.value.code == APPLICATION_001.code
74+
75+
async def test_raises_gen_003_status_when_application_not_pending(self) -> None:
76+
reviewer_id = uuid4()
77+
app = _mock_application(status="APPROVED", reviewer_id=reviewer_id)
78+
db = AsyncMock()
79+
db.scalar.return_value = app
80+
with pytest.raises(AppException) as exc:
81+
await cancel_application(db, str(app.id), str(reviewer_id))
82+
assert exc.value.code == GEN_003_STATUS.code
83+
84+
async def test_deletes_application_when_owner_and_pending(self) -> None:
85+
reviewer_id = uuid4()
86+
app = _mock_application(status="PENDING", reviewer_id=reviewer_id)
87+
db = AsyncMock()
88+
db.scalar.return_value = app
89+
await cancel_application(db, str(app.id), str(reviewer_id))
90+
db.delete.assert_called_once_with(app)
91+
db.commit.assert_awaited_once()
92+
93+
94+
@pytest.mark.asyncio
95+
class TestSubmitReview:
96+
async def test_raises_application_002_when_application_not_found(self) -> None:
97+
db = AsyncMock()
98+
db.scalar.return_value = None
99+
data = ReviewSubmissionReq(imageList=[], comment="x")
100+
with pytest.raises(AppException) as exc:
101+
await submit_review(db, str(uuid4()), str(uuid4()), data)
102+
assert exc.value.code == APPLICATION_002.code
103+
104+
async def test_raises_application_001_when_requester_is_not_reviewer(self) -> None:
105+
reviewer_id = uuid4()
106+
other_id = uuid4()
107+
app = _mock_application(reviewer_id=reviewer_id)
108+
db = AsyncMock()
109+
db.scalar.return_value = app
110+
data = ReviewSubmissionReq(imageList=[], comment="x")
111+
with pytest.raises(AppException) as exc:
112+
await submit_review(db, str(app.id), str(other_id), data)
113+
assert exc.value.code == APPLICATION_001.code
114+
115+
async def test_raises_application_003_when_submission_already_exists(self) -> None:
116+
reviewer_id = uuid4()
117+
app = _mock_application(reviewer_id=reviewer_id)
118+
existing = MagicMock()
119+
db = AsyncMock()
120+
db.scalar.side_effect = [app, existing]
121+
data = ReviewSubmissionReq(imageList=[], comment="x")
122+
with pytest.raises(AppException) as exc:
123+
await submit_review(db, str(app.id), str(reviewer_id), data)
124+
assert exc.value.code == APPLICATION_003_SUBMIT.code
125+
126+
async def test_creates_submission_and_images(self) -> None:
127+
reviewer_id = uuid4()
128+
app = _mock_application(reviewer_id=reviewer_id)
129+
db = AsyncMock()
130+
db.scalar.side_effect = [app, None] # app found, no existing submission
131+
data = ReviewSubmissionReq(imageList=["img1.jpg", "img2.jpg"], comment="Great!")
132+
await submit_review(db, str(app.id), str(reviewer_id), data)
133+
assert db.add.call_count == 3 # 1 ReviewSubmission + 2 ReviewImage
134+
db.flush.assert_awaited_once()
135+
db.commit.assert_awaited_once()
136+
137+
async def test_creates_submission_with_no_images(self) -> None:
138+
reviewer_id = uuid4()
139+
app = _mock_application(reviewer_id=reviewer_id)
140+
db = AsyncMock()
141+
db.scalar.side_effect = [app, None]
142+
data = ReviewSubmissionReq(imageList=[], comment="Just text!")
143+
await submit_review(db, str(app.id), str(reviewer_id), data)
144+
assert db.add.call_count == 1 # only ReviewSubmission
145+
146+
147+
@pytest.mark.asyncio
148+
class TestCreateApplication:
149+
async def test_raises_gen_005_on_invalid_wallet_address(self) -> None:
150+
db = AsyncMock()
151+
data = ApplicationCreateReq(
152+
eventId=str(uuid4()),
153+
walletAddress="not-a-wallet",
154+
imageKey="img.jpg",
155+
)
156+
from fastapi import BackgroundTasks
157+
158+
with pytest.raises(AppException) as exc:
159+
await create_application(db, str(uuid4()), data, BackgroundTasks())
160+
assert exc.value.code == GEN_005.code
161+
db.scalar.assert_not_awaited()
162+
163+
async def test_raises_gen_005_on_wallet_without_0x_prefix(self) -> None:
164+
db = AsyncMock()
165+
data = ApplicationCreateReq(
166+
eventId=str(uuid4()),
167+
walletAddress="AbCdEf1234567890AbCdEf1234567890AbCdEf12",
168+
imageKey="img.jpg",
169+
)
170+
from fastapi import BackgroundTasks
171+
172+
with pytest.raises(AppException) as exc:
173+
await create_application(db, str(uuid4()), data, BackgroundTasks())
174+
assert exc.value.code == GEN_005.code
175+
176+
async def test_raises_event_001_when_event_not_found(self) -> None:
177+
db = AsyncMock()
178+
db.scalar.return_value = None
179+
data = ApplicationCreateReq(
180+
eventId=str(uuid4()),
181+
walletAddress=_VALID_WALLET,
182+
imageKey="img.jpg",
183+
)
184+
from fastapi import BackgroundTasks
185+
186+
with pytest.raises(AppException) as exc:
187+
await create_application(db, str(uuid4()), data, BackgroundTasks())
188+
assert exc.value.code == EVENT_001.code
189+
190+
async def test_raises_gen_003_when_event_is_not_active(self) -> None:
191+
event = _mock_event(is_active=False)
192+
db = AsyncMock()
193+
db.scalar.return_value = event
194+
data = ApplicationCreateReq(
195+
eventId=str(event.id),
196+
walletAddress=_VALID_WALLET,
197+
imageKey="img.jpg",
198+
)
199+
from fastapi import BackgroundTasks
200+
201+
with pytest.raises(AppException) as exc:
202+
await create_application(db, str(uuid4()), data, BackgroundTasks())
203+
assert exc.value.code == GEN_003_CLOSED.code
204+
205+
async def test_raises_application_003_when_already_applied(self) -> None:
206+
event = _mock_event(is_active=True)
207+
existing = MagicMock()
208+
db = AsyncMock()
209+
db.scalar.side_effect = [event, existing]
210+
data = ApplicationCreateReq(
211+
eventId=str(event.id),
212+
walletAddress=_VALID_WALLET,
213+
imageKey="img.jpg",
214+
)
215+
from fastapi import BackgroundTasks
216+
217+
with pytest.raises(AppException) as exc:
218+
await create_application(db, str(uuid4()), data, BackgroundTasks())
219+
assert exc.value.code == APPLICATION_003_APPLY.code
220+
221+
async def test_creates_application_with_approved_status(self) -> None:
222+
event = _mock_event(is_active=True)
223+
reviewer_id = uuid4()
224+
db = AsyncMock()
225+
db.scalar.side_effect = [event, None] # event found, no duplicate
226+
data = ApplicationCreateReq(
227+
eventId=str(event.id),
228+
walletAddress=_VALID_WALLET,
229+
imageKey="img.jpg",
230+
)
231+
from fastapi import BackgroundTasks
232+
233+
with patch(
234+
"app.services.application._validate_image_condition",
235+
new_callable=AsyncMock,
236+
):
237+
await create_application(db, str(reviewer_id), data, BackgroundTasks())
238+
added = db.add.call_args[0][0]
239+
assert added.status == "APPROVED"
240+
241+
async def test_schedules_payout_when_contract_address_set(self) -> None:
242+
contract = "0xDeAdBeEf00000000000000000000000000001234"
243+
event = _mock_event(is_active=True, contract_address=contract)
244+
reviewer = MagicMock()
245+
reviewer.email = "rev@example.com"
246+
reviewer_id = uuid4()
247+
db = AsyncMock()
248+
db.scalar.side_effect = [event, None, reviewer] # event, no dup, reviewer
249+
data = ApplicationCreateReq(
250+
eventId=str(event.id),
251+
walletAddress=_VALID_WALLET,
252+
imageKey="img.jpg",
253+
)
254+
from fastapi import BackgroundTasks
255+
256+
bg = BackgroundTasks()
257+
with patch(
258+
"app.services.application._validate_image_condition",
259+
new_callable=AsyncMock,
260+
):
261+
await create_application(db, str(reviewer_id), data, bg)
262+
assert len(bg.tasks) == 1
263+
264+
async def test_no_payout_scheduled_when_no_contract_address(self) -> None:
265+
event = _mock_event(is_active=True, contract_address=None)
266+
reviewer_id = uuid4()
267+
db = AsyncMock()
268+
db.scalar.side_effect = [event, None]
269+
data = ApplicationCreateReq(
270+
eventId=str(event.id),
271+
walletAddress=_VALID_WALLET,
272+
imageKey="img.jpg",
273+
)
274+
from fastapi import BackgroundTasks
275+
276+
bg = BackgroundTasks()
277+
with patch(
278+
"app.services.application._validate_image_condition",
279+
new_callable=AsyncMock,
280+
):
281+
await create_application(db, str(reviewer_id), data, bg)
282+
assert len(bg.tasks) == 0

0 commit comments

Comments
Β (0)