class TestAccountsApi: def test_list_empty(self, api): d = api.get("/gmail/accounts").json() assert d["accounts"] == [] and d["count"] == 0 def test_seeded_account_shape(self, api, db): aid = _seed_account(db, user_id_of(api)) d = api.get("/gmail/accounts").json() assert d["count"] == 1 a = d["accounts"][0] assert {"id", "email", "status", "sending_method", "daily_limit", "campaign_limit", "sends_today", "has_refresh_token", "authorized"} <= set(a) assert a["id"] == aid and a["authorized"] is True assert a["has_refresh_token"] is True def test_patch_updates_limits_and_method(self, api, db): aid = _seed_account(db, user_id_of(api), method="api") r = api.patch(f"/gmail/accounts/{aid}", headers=H(api), json={"sending_method": "smtp", "daily_limit": 50, "campaign_limit": 25}) assert r.status_code == 200, r.text d = r.json() assert d["sending_method"] == "smtp" assert d["daily_limit"] == 50 and d["campaign_limit"] == 25 def test_patch_404_unknown(self, api): r = api.patch("/gmail/accounts/999", headers=H(api), json={"sending_method": "api", "daily_limit": 5, "campaign_limit": 5}) assert r.status_code == 404 def test_connect_without_config_is_honest_400(self, api): r = api.post("/gmail/accounts/connect", headers=H(api)) assert r.status_code == 400 assert "client configuration" in r.text.lower() def test_reauthorize_without_config_400(self, api, db): aid = _seed_account(db, user_id_of(api)) r = api.post(f"/gmail/accounts/{aid}/reauthorize", headers=H(api)) assert r.status_code == 400 def test_revoke_and_delete_account(self, api, db): aid = _seed_account(db, user_id_of(api)) r = api.post(f"/gmail/accounts/{aid}/revoke", headers=H(api)) assert r.status_code == 200, r.text d = r.json() assert d["ok"] is True and "revoked_at_google" in d # revoked → is_active False, status revoked, token deleted d2 = api.get("/gmail/accounts").json() assert d2["accounts"][0]["status"] == "revoked" assert d2["accounts"][0]["authorized"] is False from app.models import OAuthToken assert db.query(OAuthToken).count() == 0 # DELETE removes the row entirely r3 = api.delete(f"/gmail/accounts/{aid}", headers=H(api)) assert r3.status_code == 200 assert api.get("/gmail/accounts").json()["count"] == 0 def test_revoke_deletes_token_row_and_sets_flags(self, api, db): aid = _seed_account(db, user_id_of(api)) api.post(f"/gmail/accounts/{aid}/revoke", headers=H(api)) from app.models import GmailAccount acc = db.get(GmailAccount, aid) assert acc.status == "revoked" and acc.is_active is False assert acc.revoked_by_user is True and acc.revoked_at is not None def test_clear_all(self, api, db): _seed_account(db, user_id_of(api), email="a1@x.com") _seed_account(db, user_id_of(api), email="a2@x.com") r = api.post("/gmail/accounts/clear-all", headers=H(api)) assert r.status_code == 200 d = r.json() assert d["removed"] == 2 assert api.get("/gmail/accounts").json()["count"] == 0 # audit preserved acts = api.get("/logs/audit").json() assert any(a["action"] == "gmail.clear_all" for a in acts["audit"]) def test_account_isolation(self, api, db, second_user): # second user's account must be invisible and untouchable aid = _seed_account(db, user_id_of(api)) assert second_user.get("/gmail/accounts").json()["accounts"] == [] r = second_user.patch(f"/gmail/accounts/{aid}", headers=H(second_user), json={"sending_method": "api", "daily_limit": 1, "campaign_limit": 1}) assert r.status_code == 404 r2 = second_user.post(f"/gmail/accounts/{aid}/revoke", headers=H(second_user)) assert r2.status_code == 404 def test_import_config_valid_json(self, api): cfg = {"installed": {"client_id": "abc-123.apps.googleusercontent.com", "client_secret": "SECRET-XYZ", "project_id": "my-project", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token"}} r = api.post("/gmail/accounts/import-config", headers=H(api), files={"file": ("client_secret.json", json.dumps(cfg).encode(), "application/json")}) assert r.status_code in (200, 201), r.text d = r.json() assert d["ok"] is True and d["project_id"] == "my-project" # secret is NEVER echoed back, only a preview assert "SECRET-XYZ" not in r.text assert d["client_id_preview"].startswith("abc-123") # after import, connect can build a real authorization URL r2 = api.post("/gmail/accounts/connect", headers=H(api)) assert r2.status_code == 200, r2.text assert "accounts.google.com" in r2.json()["authorization_url"] def test_import_config_rejects_invalid(self, api): r = api.post("/gmail/accounts/import-config", headers=H(api), files={"file": ("bad.json", b'{"nope": true}', "application/json")}) assert r.status_code == 400 r2 = api.post("/gmail/accounts/import-config", headers=H(api), files={"file": ("notjson.json", b"not json", "application/json")}) assert r2.status_code == 400 r3 = api.post("/gmail/accounts/import-config", headers=H(api), files={"file": ("cfg.txt", b"{}", "text/plain")}) assert r3.status_code == 400 def test_requires_auth(self, api): from starlette.testclient import TestClient anon = TestClient(api.app) assert anon.get("/gmail/accounts").status_code == 401 assert anon.post("/gmail/accounts/clear-all").status_code == 401 def test_csrf_required_on_mutations(self, api): r = api.post("/gmail/accounts/clear-all") assert r.status_code == 403 r2 = api.patch("/gmail/accounts/1", json={}) assert r2.status_code == 403 # --------------------------------------------------------------------------- test-send