Skip to content

Commit 428d12d

Browse files
committed
test: cover erasure error paths and wiring selectors
PyMongoError propagation on the erasure repo methods, filter shapes on the satellite delete_many delegates, and build_account_erasure_service composition (noop/mock vs configured edge+R2+CF branches). Covers all diff lines except the two inside the worker's _build_runtime.
1 parent d48648c commit 428d12d

7 files changed

Lines changed: 219 additions & 5 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Account-erasure bulk deletes on the satellite repositories.
2+
3+
Each is a one-line ``_delete_many`` delegate; what matters is the filter
4+
key — a wrong key silently erases nothing (or worse, everything).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from unittest.mock import AsyncMock, MagicMock
10+
11+
import pytest
12+
13+
from repositories.page_layout_repository import PageLayoutRepository
14+
from repositories.webhook_delivery_repository import WebhookDeliveryRepository
15+
from repositories.webhook_endpoint_repository import WebhookEndpointRepository
16+
from repositories.webhook_event_repository import WebhookEventRepository
17+
18+
from .conftest import USER_OID, make_collection
19+
20+
21+
@pytest.mark.parametrize(
22+
("repo_cls", "method", "filter_key"),
23+
[
24+
(PageLayoutRepository, "delete_by_user", "user_id"),
25+
(WebhookEndpointRepository, "delete_by_user", "user_id"),
26+
(WebhookDeliveryRepository, "delete_by_user", "user_id"),
27+
(WebhookEventRepository, "delete_by_owner", "owner_id"),
28+
],
29+
)
30+
@pytest.mark.asyncio
31+
async def test_erasure_delete_filters_on_the_user(repo_cls, method, filter_key):
32+
col = make_collection()
33+
col.delete_many = AsyncMock(return_value=MagicMock(deleted_count=5))
34+
count = await getattr(repo_cls(col), method)(USER_OID)
35+
col.delete_many.assert_awaited_once_with({filter_key: USER_OID})
36+
assert count == 5

tests/unit/repositories/test_feature_flag_repository.py

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

88
import pytest
99
from bson import ObjectId
10+
from pymongo.errors import PyMongoError
1011

1112
from .conftest import make_collection
1213

@@ -115,3 +116,12 @@ async def test_pulls_id_and_both_email_casings_from_every_flag(self):
115116
},
116117
)
117118
assert count == 2
119+
120+
@pytest.mark.asyncio
121+
async def test_propagates_pymongo_error(self):
122+
col = make_collection()
123+
col.update_many = AsyncMock(side_effect=PyMongoError("conn lost"))
124+
with pytest.raises(PyMongoError):
125+
await self._repo(col).pull_allowlisted(
126+
ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), "user@example.com"
127+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Unit tests for the report repositories' account-erasure surface."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import AsyncMock, MagicMock
6+
7+
import pytest
8+
from pymongo.errors import PyMongoError
9+
10+
from repositories.report_repository import (
11+
ReportRepository,
12+
ReportSubmissionRepository,
13+
)
14+
15+
from .conftest import USER_OID, make_collection
16+
17+
18+
class TestPullReporter:
19+
@pytest.mark.asyncio
20+
async def test_pulls_reporter_id_from_matching_reports(self):
21+
col = make_collection()
22+
col.update_many = AsyncMock(return_value=MagicMock(modified_count=3))
23+
count = await ReportRepository(col).pull_reporter(USER_OID)
24+
col.update_many.assert_awaited_once_with(
25+
{"reporter_ids": USER_OID},
26+
{"$pull": {"reporter_ids": USER_OID}},
27+
)
28+
assert count == 3
29+
30+
@pytest.mark.asyncio
31+
async def test_propagates_pymongo_error(self):
32+
col = make_collection()
33+
col.update_many = AsyncMock(side_effect=PyMongoError("conn lost"))
34+
with pytest.raises(PyMongoError):
35+
await ReportRepository(col).pull_reporter(USER_OID)
36+
37+
38+
class TestDeleteByReporter:
39+
@pytest.mark.asyncio
40+
async def test_deletes_by_id_or_followup_email(self):
41+
col = make_collection()
42+
col.delete_many = AsyncMock(return_value=MagicMock(deleted_count=2))
43+
count = await ReportSubmissionRepository(col).delete_by_reporter(
44+
USER_OID, "user@example.com"
45+
)
46+
col.delete_many.assert_awaited_once_with(
47+
{
48+
"$or": [
49+
{"reporter_id": USER_OID},
50+
{"reporter_email": "user@example.com"},
51+
]
52+
}
53+
)
54+
assert count == 2

tests/unit/repositories/test_url_repository.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,3 +655,18 @@ async def test_iter_by_owner_refuses_anonymous_sentinel(self):
655655
async for _ in self._repo(col).iter_by_owner(ANONYMOUS_OWNER_ID):
656656
pass
657657
col.find.assert_not_called()
658+
659+
@pytest.mark.asyncio
660+
async def test_iter_by_owner_propagates_pymongo_error(self):
661+
class _DeadCursor:
662+
def __aiter__(self):
663+
return self
664+
665+
async def __anext__(self):
666+
raise OperationFailure("cursor died mid-stream")
667+
668+
col = make_collection()
669+
col.find = MagicMock(return_value=_DeadCursor())
670+
with pytest.raises(OperationFailure):
671+
async for _ in self._repo(col).iter_by_owner(USER_OID):
672+
pass

tests/unit/repositories/test_user_repository.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,17 @@ async def test_find_purge_due_empty(self):
255255
== []
256256
)
257257

258+
@pytest.mark.asyncio
259+
async def test_find_purge_due_propagates_pymongo_error(self):
260+
col = make_collection()
261+
col.find.return_value.to_list = AsyncMock(
262+
side_effect=OperationFailure("conn lost")
263+
)
264+
with pytest.raises(OperationFailure):
265+
await self._repo(col).find_purge_due(
266+
now=datetime.now(timezone.utc), limit=25
267+
)
268+
258269
@pytest.mark.asyncio
259270
async def test_delete_hard_removes_doc(self):
260271
col = make_collection()

tests/unit/routes/test_account_deletion.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,13 @@ async def test_request_deletion_computes_deadline_when_readback_races():
422422
)
423423

424424
assert purge_after >= before + timedelta(days=GRACE_DAYS, seconds=-5)
425+
426+
427+
def test_get_account_deletion_service_reads_app_state():
428+
"""The real dependency (overridden everywhere above) resolves from
429+
app.state, where wire_services parks the singleton."""
430+
from unittest.mock import MagicMock
431+
432+
request = MagicMock()
433+
resolved = get_account_deletion_service(request)
434+
assert resolved is request.app.state.account_deletion_service

tests/unit/test_erasure_wiring.py

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,56 @@
33
``build_erasure_mailer`` / ``build_posthog_eraser`` pick the real
44
integration when its settings are configured and the Noop otherwise —
55
AppSettings is env-only, so both branches are probed via env vars.
6+
``build_account_erasure_service`` (the worker-side composition of the
7+
same cascade) is probed the same way: env decides which side-effect
8+
clients get wired, primitives are fakes.
69
"""
710

811
from unittest.mock import MagicMock
912

1013
import pytest
1114

1215
from config import AppSettings
13-
from dependencies.wiring import build_erasure_mailer, build_posthog_eraser
16+
from dependencies.wiring import (
17+
build_account_erasure_service,
18+
build_erasure_mailer,
19+
build_posthog_eraser,
20+
)
21+
from infrastructure.cloudflare_kv import CloudflareKVClient
1422
from infrastructure.email.zeptomail import ZeptoMailProvider
1523
from infrastructure.posthog_erasure import HttpPostHogEraser
24+
from infrastructure.storage.r2 import R2StorageClient
1625
from services.account_erasure_service import (
26+
AccountErasureService,
1727
NoopErasureMailer,
1828
NoopPostHogEraser,
1929
)
30+
from services.cf_saas_backend import CfSaasBackend
31+
from services.edge_cache.og_writethrough import OgEdgeWritethrough
32+
from services.mock_dcv_backend import MockDcvBackend
33+
34+
_SIDE_EFFECT_ENV = (
35+
"ZEPTO_API_TOKEN",
36+
"POSTHOG_ERASURE_API_KEY",
37+
"POSTHOG_ERASURE_PROJECT_ID",
38+
"POSTHOG_ERASURE_HOST",
39+
"EDGE_CACHE_CF_ACCOUNT_ID",
40+
"EDGE_CACHE_CF_API_TOKEN",
41+
"EDGE_CACHE_KV_NAMESPACE_ID",
42+
"R2_ACCOUNT_ID",
43+
"R2_ACCESS_KEY_ID",
44+
"R2_SECRET_ACCESS_KEY",
45+
"R2_BUCKET",
46+
"R2_PUBLIC_BASE_URL",
47+
"CUSTOM_DOMAINS_MOCK_DCV",
48+
)
2049

2150

2251
@pytest.fixture
2352
def base_env(monkeypatch):
2453
monkeypatch.setenv("MONGODB_URI", "mongodb://localhost:27017/")
25-
monkeypatch.delenv("ZEPTO_API_TOKEN", raising=False)
26-
monkeypatch.delenv("POSTHOG_ERASURE_API_KEY", raising=False)
27-
monkeypatch.delenv("POSTHOG_ERASURE_PROJECT_ID", raising=False)
28-
monkeypatch.delenv("POSTHOG_ERASURE_HOST", raising=False)
54+
for var in _SIDE_EFFECT_ENV:
55+
monkeypatch.delenv(var, raising=False)
2956
return monkeypatch
3057

3158

@@ -81,3 +108,54 @@ def test_host_override(self, base_env):
81108
eraser = build_posthog_eraser(AppSettings(), MagicMock())
82109
assert isinstance(eraser, HttpPostHogEraser)
83110
assert eraser._host == "https://us.posthog.com"
111+
112+
113+
class TestBuildAccountErasureService:
114+
"""Worker-side composition root: same env gates as the app wiring."""
115+
116+
def _build(self):
117+
# Mock db mapping, inert http client, redis None (cache
118+
# invalidation degrades to no-ops, as in workers without Redis).
119+
return build_account_erasure_service(
120+
MagicMock(), AppSettings(), MagicMock(), None
121+
)
122+
123+
def test_minimal_env_wires_disabled_side_effects(self, base_env):
124+
service = self._build()
125+
assert isinstance(service, AccountErasureService)
126+
# Edge cache + R2 unconfigured ⇒ no KV purge, no og write-through,
127+
# no R2 sweep.
128+
assert service._r2_storage is None
129+
assert service._url_service._edge_kv is None
130+
assert service._url_service._og_writethrough is None
131+
assert service._url_service._r2_storage is None
132+
# Unconfigured mail/PostHog degrade to Noops, as in the app.
133+
assert isinstance(service._mailer, NoopErasureMailer)
134+
assert isinstance(service._posthog, NoopPostHogEraser)
135+
136+
def test_default_dcv_selects_cf_saas_backend(self, base_env):
137+
service = self._build()
138+
assert isinstance(service._domain_service._edge, CfSaasBackend)
139+
140+
def test_mock_dcv_selects_mock_backend(self, base_env):
141+
base_env.setenv("CUSTOM_DOMAINS_MOCK_DCV", "true")
142+
service = self._build()
143+
assert isinstance(service._domain_service._edge, MockDcvBackend)
144+
145+
def test_configured_edge_and_r2_wire_real_clients(self, base_env):
146+
base_env.setenv("EDGE_CACHE_CF_ACCOUNT_ID", "acct")
147+
base_env.setenv("EDGE_CACHE_CF_API_TOKEN", "kv-token")
148+
base_env.setenv("EDGE_CACHE_KV_NAMESPACE_ID", "ns")
149+
base_env.setenv("R2_ACCOUNT_ID", "acct")
150+
base_env.setenv("R2_ACCESS_KEY_ID", "key")
151+
base_env.setenv("R2_SECRET_ACCESS_KEY", "secret")
152+
base_env.setenv("R2_BUCKET", "og-images")
153+
base_env.setenv("R2_PUBLIC_BASE_URL", "https://og.spoo.me")
154+
service = self._build()
155+
url_service = service._url_service
156+
assert isinstance(url_service._edge_kv, CloudflareKVClient)
157+
assert isinstance(url_service._og_writethrough, OgEdgeWritethrough)
158+
assert isinstance(url_service._r2_storage, R2StorageClient)
159+
# The erasure R2 sweep and the URL service delete path must share
160+
# ONE client — a split here would orphan uploaded og:images.
161+
assert service._r2_storage is url_service._r2_storage

0 commit comments

Comments
 (0)