SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

accounts: tests des flux (inscription→vérification→clé→révocation), admin/invitation, CLI seed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent dddc5b3

3 changed files +401 −0

added tests/test_accounts_admin.py +149 −0
@@ -0,0 +1,149 @@
1 +"""Admin API: invite flow → accept → login, tier/status updates, keys, global usage, audit log, role guard."""
2 +from __future__ import annotations
3 +
4 +import os
5 +from urllib.parse import parse_qs, urlparse
6 +
7 +JSON = {"Content-Type": "application/json"}
8 +
9 +
10 +def token_of(link: str) -> str:
11 + return parse_qs(urlparse(link).query)["token"][0]
12 +
13 +
14 +def fresh_email(tag: str) -> str:
15 + return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com"
16 +
17 +
18 +def admin_headers(make_user) -> dict:
19 + _, key, _, _ = make_user(role="admin")
20 + return {**JSON, "Authorization": f"Bearer {key}"}
21 +
22 +
23 +def test_non_admin_is_forbidden(client, make_user):
24 + _, key, _, _ = make_user()
25 + r = client.get("/v1/admin/users", headers={"Authorization": f"Bearer {key}"})
26 + assert r.status_code == 403 and r.json()["error"]["code"] == "FORBIDDEN"
27 + assert client.get("/v1/admin/users").status_code == 401
28 +
29 +
30 +def test_invite_flow(client, make_user):
31 + ah = admin_headers(make_user)
32 + email = fresh_email("tim")
33 + r = client.post("/v1/admin/users", json={"email": email, "name": "Tim", "tier": "high_usage"}, headers=ah)
34 + assert r.status_code == 201, r.text
35 + d = r.json()["data"]
36 + assert d["user"]["status"] == "invited" and d["user"]["tier"] == "high_usage" and d["user"]["keys_active"] == 1
37 + assert d["key"]["prefix"].startswith("hfmd_live_") and "key" not in d["key"] # admins never see the full key here
38 + assert d["invitation"]["delivered"] is False and "/accept-invite?token=" in d["invitation"]["link"]
39 + uid = d["user"]["id"]
40 + # duplicate invite → 409
41 + r = client.post("/v1/admin/users", json={"email": email, "name": "Tim"}, headers=ah)
42 + assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN"
43 + # detail shows the pending link
44 + r = client.get(f"/v1/admin/users/{uid}", headers=ah)
45 + assert r.status_code == 200 and r.json()["data"]["pending_invite_link"] == d["invitation"]["link"]
46 + assert len(r.json()["data"]["keys"]) == 1
47 + # user cannot log in yet (no password)
48 + r = client.post("/v1/auth/login", json={"email": email, "password": "whatever-long-enough"}, headers=JSON)
49 + assert r.status_code == 401
50 + # re-send voids the first token
51 + r = client.post(f"/v1/admin/users/{uid}/invite", json={}, headers=ah)
52 + assert r.status_code == 200
53 + link2 = r.json()["data"]["invitation"]["link"]
54 + r = client.post("/v1/auth/accept-invite", json={"token": token_of(d["invitation"]["link"]), "password": "tim-passphrase-123"}, headers=JSON)
55 + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN"
56 + # accept with the fresh one → active + session
57 + r = client.post("/v1/auth/accept-invite", json={"token": token_of(link2), "password": "tim-passphrase-123"}, headers=JSON)
58 + assert r.status_code == 200 and r.json()["data"]["status"] == "active" and r.json()["data"]["email_verified"] is True
59 + me = client.get("/v1/me").json()["data"]
60 + assert me["user"]["email"] == email and me["limits"]["tier"] == "high_usage" and me["user"]["keys_active"] == 1
61 + client.cookies.clear()
62 + r = client.post("/v1/auth/login", json={"email": email, "password": "tim-passphrase-123"}, headers=JSON)
63 + assert r.status_code == 200
64 + client.cookies.clear()
65 + # signing up again with an invited-then-active address → EMAIL_TAKEN
66 + r = client.post("/v1/auth/signup", json={"email": email, "name": "Tim", "password": "another-passphrase"}, headers=JSON)
67 + assert r.status_code == 409
68 +
69 +
70 +def test_signup_on_invited_address_resends_invitation(client, make_user):
71 + ah = admin_headers(make_user)
72 + email = fresh_email("luca")
73 + client.post("/v1/admin/users", json={"email": email, "name": "Luca"}, headers=ah)
74 + r = client.post("/v1/auth/signup", json={"email": email, "name": "Luca", "password": "luca-passphrase-123"}, headers=JSON)
75 + assert r.status_code == 202 and r.json()["data"]["status"] == "invitation_sent"
76 + assert "/accept-invite?token=" in r.json()["data"]["debug_link"]
77 +
78 +
79 +def test_create_active_user_update_tier_and_disable(client, make_user):
80 + ah = admin_headers(make_user)
81 + email = fresh_email("ada")
82 + r = client.post("/v1/admin/users", json={"email": email, "name": "Ada", "password": "ada-passphrase-123"}, headers=ah)
83 + assert r.status_code == 201 and r.json()["data"]["user"]["status"] == "active" and r.json()["data"]["invitation"] is None
84 + uid = r.json()["data"]["user"]["id"]
85 + # admin creates a key for her (shown once), it honours the user tier then the override
86 + r = client.post(f"/v1/admin/users/{uid}/keys", json={"name": "handover"}, headers=ah)
87 + assert r.status_code == 201
88 + key = r.json()["data"]["key"]
89 + assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "120"
90 + r = client.patch(f"/v1/admin/users/{uid}", json={"tier": "high_usage"}, headers=ah)
91 + assert r.status_code == 200 and r.json()["data"]["tier"] == "high_usage"
92 + assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "600"
93 + r = client.patch(f"/v1/admin/users/{uid}", json={"tier": "platinum"}, headers=ah)
94 + assert r.status_code == 422
95 + # disable → key refused, login refused
96 + r = client.patch(f"/v1/admin/users/{uid}", json={"status": "disabled"}, headers=ah)
97 + assert r.status_code == 200
98 + r = client.get("/v1/status", headers={"Authorization": f"Bearer {key}"})
99 + assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
100 + r = client.post("/v1/auth/login", json={"email": email, "password": "ada-passphrase-123"}, headers=JSON)
101 + assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
102 + # re-enable and revoke via admin
103 + client.patch(f"/v1/admin/users/{uid}", json={"status": "active"}, headers=ah)
104 + kid = client.get(f"/v1/admin/users/{uid}", headers=ah).json()["data"]["keys"][0]["id"]
105 + r = client.delete(f"/v1/admin/users/{uid}/keys/{kid}", headers=ah)
106 + assert r.status_code == 200 and r.json()["data"]["status"] == "revoked"
107 + assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401
108 + # reset link for the user
109 + r = client.post(f"/v1/admin/users/{uid}/reset-password", json={}, headers=ah)
110 + assert r.status_code == 200 and "/reset-password?token=" in r.json()["data"]["reset"]["link"]
111 + assert client.get("/v1/admin/users/999999", headers=ah).json()["error"]["code"] == "USER_NOT_FOUND"
112 +
113 +
114 +def test_list_users_pagination_and_search(client, make_user):
115 + ah = admin_headers(make_user)
116 + emails = [fresh_email("page") for _ in range(3)]
117 + for e in emails:
118 + client.post("/v1/admin/users", json={"email": e, "name": "Paged Person"}, headers=ah)
119 + r = client.get("/v1/admin/users?search=paged&limit=2", headers=ah)
120 + assert r.status_code == 200
121 + body = r.json()
122 + assert len(body["data"]) == 2 and body["meta"]["next_cursor"]
123 + r2 = client.get(f"/v1/admin/users?search=paged&limit=2&cursor={body['meta']['next_cursor']}", headers=ah)
124 + assert r2.status_code == 200 and len(r2.json()["data"]) >= 1
125 + ids = {u["id"] for u in body["data"]} | {u["id"] for u in r2.json()["data"]}
126 + assert len(ids) >= 3
127 + assert client.get("/v1/admin/users?limit=0", headers=ah).status_code == 422
128 +
129 +
130 +def test_global_usage_and_audit(client, make_user):
131 + ah = admin_headers(make_user)
132 + uid, key, _, _ = make_user()
133 + client.get("/v1/_test/frame?n=100", headers={"Authorization": f"Bearer {key}"})
134 + import time
135 +
136 + from ratelimit import usage
137 + usage.fold(now_s=time.time() + 120) # force the current minute to fold
138 + r = client.get("/v1/admin/usage?days=7&top=5", headers=ah)
139 + assert r.status_code == 200
140 + d = r.json()["data"]
141 + assert d["per_day"] and d["per_day"][-1]["requests"] >= 1
142 + top = [t for t in d["top"] if t["user"] and t["user"]["id"] == uid]
143 + assert top and top[0]["rows"] >= 100 and top[0]["user"]["email"]
144 + r = client.get("/v1/admin/audit?limit=5", headers=ah)
145 + assert r.status_code == 200
146 + rows = r.json()["data"]
147 + assert rows and {"id", "ts", "actor", "action", "target", "meta"} <= set(rows[0])
148 + r = client.get("/v1/admin/audit?action=key.", headers=ah)
149 + assert all(x["action"].startswith("key.") for x in r.json()["data"])
added tests/test_accounts_cli.py +74 −0
@@ -0,0 +1,74 @@
1 +"""`hfmd` CLI: idempotent seed, users add/list/set-tier/disable, keys list, --show-key discipline."""
2 +from __future__ import annotations
3 +
4 +from sqlalchemy import func, select
5 +
6 +
7 +def run(cli, capsys, *argv) -> tuple[int, str]:
8 + rc = cli.main(list(argv))
9 + out = capsys.readouterr()
10 + return rc, out.out + out.err
11 +
12 +
13 +def test_seed_is_idempotent(app, capsys):
14 + from accounts import cli
15 + from accounts.models import ApiKey, EmailToken, User
16 + from core.db import session
17 +
18 + rc, out = run(cli, capsys, "seed")
19 + assert rc == 0
20 + for email in ("ianez84@gmail.com", "timerdmann.uni@gmail.com", "javier.slaton@gmail.com", "contact@spboucher.ai"):
21 + assert email in out
22 + assert "accept-invite?token=" in out and "hfmd_live_" in out
23 + assert out.count("hfmd_live_") == 4 # prefixes only, one per user
24 + assert "api_key" not in out # full keys hidden without --show-key
25 +
26 + def counts():
27 + with session() as s:
28 + users = {u.email: u for u in s.execute(select(User)).scalars()}
29 + keys = s.execute(select(func.count()).select_from(ApiKey).where(ApiKey.status == "active",
30 + ApiKey.user_id.in_([u.id for u in users.values()]))).scalar_one()
31 + toks = s.execute(select(func.count()).select_from(EmailToken).where(EmailToken.used_at.is_(None),
32 + EmailToken.user_id.in_([u.id for u in users.values()]))).scalar_one()
33 + return users, keys, toks
34 +
35 + users, keys, toks = counts()
36 + seeded = {e: users[e] for e in ("ianez84@gmail.com", "timerdmann.uni@gmail.com", "javier.slaton@gmail.com", "contact@spboucher.ai")}
37 + assert seeded["contact@spboucher.ai"].role == "admin" and seeded["ianez84@gmail.com"].role == "user"
38 + assert all(u.status == "invited" for u in seeded.values())
39 +
40 + rc, out2 = run(cli, capsys, "seed")
41 + assert rc == 0
42 + users2, keys2, toks2 = counts()
43 + assert len(users2) == len(users) and keys2 == keys and toks2 == toks # nothing duplicated
44 + # the same pending invitation links are printed again
45 + links1 = sorted(line.split()[-1] for line in out.splitlines() if "accept-invite?token=" in line)
46 + links2 = sorted(line.split()[-1] for line in out2.splitlines() if "accept-invite?token=" in line)
47 + assert links1 == links2 and len(links1) == 4
48 +
49 +
50 +def test_users_add_show_key_and_management(app, capsys):
51 + from accounts import cli
52 + email = "cli-person@example.com"
53 + rc, out = run(cli, capsys, "users", "add", "CLI Person", email, "--tier", "high_usage", "--show-key", "--no-mail")
54 + assert rc == 0 and email in out and "high_usage" in out
55 + full = [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42]
56 + assert len(full) == 1 # the full key, exactly once
57 + # second add: no new key, no key printed, tier untouched
58 + rc, out = run(cli, capsys, "users", "add", "CLI Person", email, "--tier", "free", "--show-key", "--no-mail")
59 + assert rc == 0 and "high_usage" in out
60 + assert not [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42]
61 + rc, out = run(cli, capsys, "keys", "list", email)
62 + assert rc == 0 and out.count("active") == 1
63 + rc, out = run(cli, capsys, "users", "set-tier", email, "free")
64 + assert rc == 0 and "tier = free" in out
65 + rc, out = run(cli, capsys, "users", "disable", email)
66 + assert rc == 0 and "status = disabled" in out
67 + rc, out = run(cli, capsys, "users", "enable", email)
68 + assert rc == 0 and "status = active" in out
69 + rc, out = run(cli, capsys, "users", "invite-resend", email, "--no-mail")
70 + assert rc == 0 and "accept-invite?token=" in out
71 + rc, out = run(cli, capsys, "users", "list")
72 + assert rc == 0 and email in out
73 + rc, out = run(cli, capsys, "keys", "list", "nobody@example.com")
74 + assert rc == 1 and "USER_NOT_FOUND" in out
added tests/test_accounts_flow.py +178 −0
@@ -0,0 +1,178 @@
1 +"""Self-service account flows: signup → verify → login → key → use → revoke → 401, reset, CSRF, /me."""
2 +from __future__ import annotations
3 +
4 +import os
5 +from urllib.parse import parse_qs, urlparse
6 +
7 +JSON = {"Content-Type": "application/json"}
8 +
9 +
10 +def token_of(link: str) -> str:
11 + return parse_qs(urlparse(link).query)["token"][0]
12 +
13 +
14 +def fresh_email(tag: str) -> str:
15 + return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com"
16 +
17 +
18 +def test_full_lifecycle(client):
19 + email = fresh_email("ada")
20 + pw = "very-secure-passphrase"
21 + # signup → 202 with (dev only) debug link
22 + r = client.post("/v1/auth/signup", json={"email": email, "name": "Ada", "password": pw}, headers=JSON)
23 + assert r.status_code == 202, r.text
24 + d = r.json()["data"]
25 + assert d["status"] == "verification_sent" and "debug_link" in d
26 + # cannot log in before verifying
27 + r = client.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
28 + assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED"
29 + # verify → session cookie
30 + r = client.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])})
31 + assert r.status_code == 200 and r.json()["data"]["email_verified"] is True
32 + assert "hfmd_session" in r.cookies
33 + # token is single use
34 + assert client.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}).json()["error"]["code"] == "INVALID_TOKEN"
35 + # /me with the cookie
36 + r = client.get("/v1/me")
37 + assert r.status_code == 200
38 + me = r.json()["data"]
39 + assert me["user"]["email"] == email and me["limits"]["tier"] == "free" and me["limits"]["requests"] == 120
40 + assert me["user"]["keys_active"] == 0
41 + # create key (shown once)
42 + r = client.post("/v1/me/keys", json={"name": "laptop"}, headers=JSON)
43 + assert r.status_code == 201
44 + k = r.json()["data"]
45 + assert k["key"].startswith("hfmd_live_") and len(k["key"]) == len("hfmd_live_") + 32
46 + assert k["prefix"] == k["key"][:18] and k["name"] == "laptop"
47 + listed = client.get("/v1/me/keys").json()["data"]
48 + assert len(listed) == 1 and "key" not in listed[0] and listed[0]["prefix"] == k["prefix"]
49 + # use the key on a data endpoint → free-tier headers
50 + client.cookies.clear()
51 + r = client.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
52 + assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120"
53 + # /me also works with the key (programmatic)
54 + assert client.get("/v1/me", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200
55 + # rotate → new key, old one dead
56 + r = client.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers={**JSON, "Authorization": f"Bearer {k['key']}"})
57 + assert r.status_code == 201
58 + k2 = r.json()["data"]
59 + assert k2["key"] != k["key"] and k2["rotated_from"] == k["id"] and k2["name"] == "laptop"
60 + r = client.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
61 + assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY"
62 + # revoke the new one → 401 immediately (cache invalidated)
63 + r = client.delete(f"/v1/me/keys/{k2['id']}", headers={**JSON, "Authorization": f"Bearer {k2['key']}"})
64 + assert r.status_code == 200 and r.json()["data"]["status"] == "revoked"
65 + assert client.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401
66 + # login again and check the session + usage endpoint
67 + r = client.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
68 + assert r.status_code == 200 and r.json()["data"]["last_login_at"]
69 + u = client.get("/v1/me/usage?range=24h").json()["data"]
70 + assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 2
71 + # logout
72 + r = client.post("/v1/auth/logout", json={}, headers=JSON)
73 + assert r.status_code == 200
74 + assert client.get("/v1/me").status_code == 401
75 + client.cookies.clear()
76 +
77 +
78 +def test_signup_validation_and_conflicts(client, make_user):
79 + _, _, email, _ = make_user()
80 + r = client.post("/v1/auth/signup", json={"email": email, "name": "x", "password": "long-enough-password"}, headers=JSON)
81 + assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN"
82 + r = client.post("/v1/auth/signup", json={"email": fresh_email("w"), "name": "x", "password": "short"}, headers=JSON)
83 + assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD"
84 + r = client.post("/v1/auth/signup", json={"email": "not-an-email", "name": "x", "password": "long-enough-password"}, headers=JSON)
85 + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER"
86 +
87 +
88 +def test_csrf_requires_json_content_type(client, make_user):
89 + _, key, _, _ = make_user()
90 + r = client.post("/v1/me/keys", content="name=x", headers={"Content-Type": "application/x-www-form-urlencoded",
91 + "Authorization": f"Bearer {key}"})
92 + assert r.status_code == 415 and r.json()["error"]["code"] == "UNSUPPORTED_MEDIA_TYPE"
93 + r = client.post("/v1/auth/logout", headers={"Content-Type": "text/plain"})
94 + assert r.status_code == 415
95 +
96 +
97 +def test_login_errors(client, make_user):
98 + _, _, email, pw = make_user()
99 + r = client.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON)
100 + assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_CREDENTIALS"
101 + r = client.post("/v1/auth/login", json={"email": "ghost@example.com", "password": pw}, headers=JSON)
102 + assert r.status_code == 401
103 + assert client.get("/v1/me").status_code == 401
104 + assert client.get("/v1/me").json()["error"]["code"] == "AUTH_REQUIRED"
105 +
106 +
107 +def test_forgot_and_reset(client, make_user):
108 + _, _, email, _ = make_user(with_key=False)
109 + # unknown address: same 202, no link
110 + r = client.post("/v1/auth/forgot", json={"email": "unknown@example.com"}, headers=JSON)
111 + assert r.status_code == 202 and "debug_link" not in r.json()["data"]
112 + r = client.post("/v1/auth/forgot", json={"email": email}, headers=JSON)
113 + assert r.status_code == 202
114 + link = r.json()["data"]["debug_link"]
115 + assert "/reset-password?token=" in link
116 + r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "short"}, headers=JSON)
117 + assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD"
118 + r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
119 + assert r.status_code == 200
120 + client.cookies.clear()
121 + r = client.post("/v1/auth/login", json={"email": email, "password": "brand-new-passphrase"}, headers=JSON)
122 + assert r.status_code == 200
123 + client.cookies.clear()
124 + r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
125 + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN"
126 +
127 +
128 +def test_key_limit(client, make_user):
129 + _, key, _, _ = make_user()
130 + h = {**JSON, "Authorization": f"Bearer {key}"}
131 + for i in range(9):
132 + assert client.post("/v1/me/keys", json={"name": f"k{i}"}, headers=h).status_code == 201
133 + r = client.post("/v1/me/keys", json={"name": "one-too-many"}, headers=h)
134 + assert r.status_code == 409 and r.json()["error"]["code"] == "KEY_LIMIT_REACHED"
135 + r = client.delete("/v1/me/keys/999999", headers=h)
136 + assert r.status_code == 404 and r.json()["error"]["code"] == "KEY_NOT_FOUND"
137 +
138 +
139 +def test_disabled_user_key_is_refused(client, make_user):
140 + uid, key, _, _ = make_user()
141 + from accounts import service
142 + from accounts.models import User
143 + from core.db import session
144 + with session() as s:
145 + service.update_user(s, s.get(User, uid), actor="test", status="disabled")
146 + r = client.get("/v1/status", headers={"Authorization": f"Bearer {key}"})
147 + assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
148 +
149 +
150 +def test_mailer_templates_never_leak_keys(app):
151 + from accounts import mailer
152 + full_key = "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"
153 + for kind in ("verify", "invite", "reset"):
154 + subject, text, html = mailer.render(kind, name="Ada", link="https://x/y?token=abc")
155 + assert subject and "HF Market Data" in text and '<a href="https://x/y?token=abc"' in html
156 + subject, text, html = mailer.render("key_created", name="Ada", key_name="k", key_prefix="hfmd_live_ab12cd34", key=full_key)
157 + assert "hfmd_live_ab12cd34" in text and full_key not in text and full_key not in html # prefix only, never the key
158 + d = mailer.send("verify", "ada@example.com", name="Ada", link="https://x/y?token=abc")
159 + assert d.delivered is False and d.error == "no_provider"
160 +
161 +
162 +def test_mailer_posts_to_resend(app, monkeypatch):
163 + from dataclasses import replace
164 +
165 + import respx
166 + from httpx import Response
167 +
168 + from accounts import mailer
169 + monkeypatch.setattr(mailer, "settings", replace(mailer.settings, resend_api_key="re_test_key"))
170 + with respx.mock(assert_all_called=True) as mock:
171 + route = mock.post(mailer.RESEND_URL).mock(return_value=Response(200, json={"id": "email_123"}))
172 + d = mailer.send("invite", "tim@example.com", name="Tim", link="https://x/accept-invite?token=t")
173 + assert d.delivered and d.provider_id == "email_123"
174 + sent = route.calls[0].request
175 + assert sent.headers["authorization"] == "Bearer re_test_key"
176 + import json
177 + body = json.loads(sent.content)
178 + assert body["to"] == ["tim@example.com"] and "accept-invite?token=t" in body["text"] and body["html"]
179