@podosoft/podokit 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +56 -2
  19. package/dist/deploy-schema.js +130 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +28 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +40 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/package.json +2 -2
  93. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  94. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  95. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  96. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  97. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  124. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  125. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  126. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  127. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  133. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  134. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  138. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  139. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  140. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  141. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  164. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  165. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  166. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  167. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  168. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  169. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  170. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  171. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  172. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  179. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  180. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  183. package/dist/templates/modules/auth/module.manifest.json +8 -2
  184. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  185. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  186. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  187. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  188. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  189. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  190. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  191. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  192. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  193. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  194. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  195. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  196. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  197. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  198. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  199. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  200. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  201. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  202. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  203. package/dist/templates/todo/AGENTS.md +5 -1
  204. package/dist/templates/todo/README.md +6 -4
  205. package/dist/text-diff.d.ts +12 -0
  206. package/dist/text-diff.js +111 -0
  207. package/dist/update.d.ts +61 -3
  208. package/dist/update.js +152 -27
  209. package/package.json +2 -2
  210. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  211. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -1,7 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
3
  import { expectObjectLength } from "../helpers/object-response";
4
- import { exclusive } from "../helpers/exclusive";
5
4
 
6
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
7
6
  const origin = { origin: base };
@@ -18,36 +17,30 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
18
17
  return ctx;
19
18
  }
20
19
 
21
- test("theme settings: valid preset/radius/accent round-trip", async ({ playwright }) => {
22
- test.slow();
23
- await exclusive("site-theme", async () => {
24
- const admin = await adminCtx(playwright);
25
- const put = await admin.put("/api/site/settings", {
26
- data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
27
- });
28
- expect(put.ok()).toBeTruthy();
20
+ test("theme settings: valid preset/radius/accent round-trip", { tag: "@exclusive" }, async ({ playwright }) => {
21
+ const admin = await adminCtx(playwright);
22
+ const put = await admin.put("/api/site/settings", {
23
+ data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
24
+ });
25
+ expect(put.ok()).toBeTruthy();
29
26
 
30
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
31
- const settings = await (await anon.get("/api/site/settings")).json();
32
- expect(settings.themePreset).toBe("slate");
33
- expect(settings.themeRadius).toBe("0.75");
34
- expect(settings.brandColor).toBe("#2563eb");
27
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
28
+ const settings = await (await anon.get("/api/site/settings")).json();
29
+ expect(settings.themePreset).toBe("slate");
30
+ expect(settings.themeRadius).toBe("0.75");
31
+ expect(settings.brandColor).toBe("#2563eb");
35
32
 
36
- // restore
37
- await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
38
- await anon.dispose();
39
- });
33
+ // restore
34
+ await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
35
+ await anon.dispose();
40
36
  });
41
37
 
42
- test("theme settings: valid per-token overrides accepted", async ({ playwright }) => {
43
- test.slow();
44
- await exclusive("site-theme", async () => {
45
- const admin = await adminCtx(playwright);
46
- const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
47
- const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
48
- expect(put.ok()).toBeTruthy();
49
- await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
50
- });
38
+ test("theme settings: valid per-token overrides accepted", { tag: "@exclusive" }, async ({ playwright }) => {
39
+ const admin = await adminCtx(playwright);
40
+ const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
41
+ const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
42
+ expect(put.ok()).toBeTruthy();
43
+ await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
51
44
  });
52
45
 
53
46
  test("theme settings: invalid values are rejected (no CSS injection)", async ({ playwright }) => {
@@ -68,36 +61,33 @@ test("theme settings: invalid values are rejected (no CSS injection)", async ({
68
61
  }
69
62
  });
70
63
 
71
- test("site settings: one response shape and immediate reads after each write", async ({ playwright }) => {
72
- test.slow();
73
- await exclusive("site-general", async () => {
74
- const admin = await adminCtx(playwright);
75
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
76
- const before = await (await anon.get("/api/site/settings")).json() as Record<string, unknown>;
77
- expect(Object.keys(before)).toEqual([
78
- "name", "description", "supportEmail", "footerText", "brandColor", "themePreset", "themeRadius",
79
- "themeOverrides", "termsUrl", "privacyUrl", "locale", "timezone", "maintenanceMode", "allowSignup",
80
- "hasFavicon", "faviconVersion",
81
- ]);
82
- expect(typeof before.hasFavicon).toBe("boolean");
83
- expect(before.faviconVersion === null || typeof before.faviconVersion === "string").toBe(true);
64
+ test("site settings: one response shape and immediate reads after each write", { tag: "@exclusive" }, async ({ playwright }) => {
65
+ const admin = await adminCtx(playwright);
66
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
67
+ const before = await (await anon.get("/api/site/settings")).json() as Record<string, unknown>;
68
+ expect(Object.keys(before)).toEqual([
69
+ "name", "description", "supportEmail", "footerText", "brandColor", "themePreset", "themeRadius",
70
+ "themeOverrides", "termsUrl", "privacyUrl", "locale", "timezone", "maintenanceMode", "allowSignup",
71
+ "hasFavicon", "faviconVersion",
72
+ ]);
73
+ expect(typeof before.hasFavicon).toBe("boolean");
74
+ expect(before.faviconVersion === null || typeof before.faviconVersion === "string").toBe(true);
84
75
 
85
- try {
86
- // The second write lands while the first read is still cached; it must not be hidden.
87
- for (const footerText of ["first footer", "second footer"]) {
88
- expect((await admin.put("/api/site/settings", { data: { footerText } })).ok()).toBeTruthy();
89
- expect((await (await anon.get("/api/site/settings")).json()).footerText).toBe(footerText);
90
- }
91
- } finally {
92
- const footerText = typeof before.footerText === "string" ? before.footerText : "";
93
- await admin.put("/api/site/settings", { data: { footerText } });
94
- await anon.dispose();
95
- await admin.dispose();
76
+ try {
77
+ // The second write lands while the first read is still cached; it must not be hidden.
78
+ for (const footerText of ["first footer", "second footer"]) {
79
+ expect((await admin.put("/api/site/settings", { data: { footerText } })).ok()).toBeTruthy();
80
+ expect((await (await anon.get("/api/site/settings")).json()).footerText).toBe(footerText);
96
81
  }
97
- });
82
+ } finally {
83
+ const footerText = typeof before.footerText === "string" ? before.footerText : "";
84
+ await admin.put("/api/site/settings", { data: { footerText } });
85
+ await anon.dispose();
86
+ await admin.dispose();
87
+ }
98
88
  });
99
89
 
100
- test("site favicon: the versioned URL is cached for good and other URLs revalidate", async ({ playwright }) => {
90
+ test("site favicon: the versioned URL is cached for good and other URLs revalidate", { tag: "@exclusive" }, async ({ playwright }) => {
101
91
  const admin = await adminCtx(playwright);
102
92
  const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
103
93
  try {
@@ -42,11 +42,13 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
42
42
  await admin.dispose();
43
43
  });
44
44
 
45
- test("a normal user cannot create users (403)", async ({ playwright }) => {
45
+ test("a normal user cannot create users (403)", async ({ playwright, disposableUsers }) => {
46
46
  const user = await signedIn(playwright, USER);
47
47
  const res = await user.post("/api/auth/admin/create-user", {
48
48
  data: { email: `nope-${Date.now()}@example.com`, password: "Podokit3e-Str0ng!pw", name: "Nope", role: "user" },
49
49
  });
50
+ // Remove the account if the request wrongly succeeded, before failing on it.
51
+ if (res.ok()) await disposableUsers.trackResponse(res);
50
52
  expect(res.status()).toBe(403);
51
53
  await user.dispose();
52
54
  });
@@ -1,7 +1,18 @@
1
+ import { appAccounts } from "./app-accounts";
2
+
1
3
  export type Account = { name: string; email: string; password: string };
2
4
 
5
+ const DEFAULT_PASSWORD = "Podokit3e-Str0ng!pw";
6
+
7
+ function fromEnv(name: string): string | undefined {
8
+ const value = process.env[name];
9
+ return value !== undefined && value.length > 0 ? value : undefined;
10
+ }
11
+
3
12
  /**
4
- * The password the suite signs in with, per account.
13
+ * The seeded accounts the suite signs in as. Each value comes from, in order,
14
+ * an `E2E_*` environment variable, the application-owned `app-accounts.ts`, and
15
+ * the generated default.
5
16
  *
6
17
  * A test must never change an existing account's credentials. `seed.setup.ts` signs up
7
18
  * idempotently and then signs in with these values, which is right on a stack the suite
@@ -19,13 +30,20 @@ export type Account = { name: string; email: string; password: string };
19
30
  * Without the override the run fails with `INVALID_EMAIL_OR_PASSWORD`, which is the correct
20
31
  * outcome — a refusal, not a silent overwrite. Do not commit a real value here.
21
32
  */
22
- function seedPassword(envVar: "E2E_ADMIN_PASSWORD" | "E2E_USER_PASSWORD"): string {
23
- const value = process.env[envVar];
24
- return value !== undefined && value.length > 0 ? value : "Podokit3e-Str0ng!pw";
33
+ function seededAccount(
34
+ role: "ADMIN" | "USER",
35
+ configured: Partial<Account> | undefined,
36
+ defaults: Pick<Account, "name" | "email">,
37
+ ): Account {
38
+ return {
39
+ name: configured?.name ?? defaults.name,
40
+ email: fromEnv(`E2E_${role}_EMAIL`) ?? configured?.email ?? defaults.email,
41
+ password: fromEnv(`E2E_${role}_PASSWORD`) ?? configured?.password ?? DEFAULT_PASSWORD,
42
+ };
25
43
  }
26
44
 
27
- export const ADMIN = { name: "Admin", email: "admin@example.com", password: seedPassword("E2E_ADMIN_PASSWORD") };
28
- export const USER = { name: "Normal User", email: "user@example.com", password: seedPassword("E2E_USER_PASSWORD") };
45
+ export const ADMIN = seededAccount("ADMIN", appAccounts.admin, { name: "Admin", email: "admin@example.com" });
46
+ export const USER = seededAccount("USER", appAccounts.user, { name: "Normal User", email: "user@example.com" });
29
47
  export const adminState = "playwright/.auth/admin.json";
30
48
  export const userState = "playwright/.auth/user.json";
31
49
  export const userBaselineState = "playwright/.auth/user-baseline.json";
@@ -0,0 +1,24 @@
1
+ import type { Account } from "./accounts";
2
+
3
+ /**
4
+ * The seeded accounts' names, addresses, or password sources when this
5
+ * application's differ from the generated `admin@example.com` and
6
+ * `user@example.com`. PodoKit creates this file once and never overwrites it.
7
+ * `E2E_ADMIN_EMAIL`, `E2E_USER_EMAIL`, `E2E_ADMIN_PASSWORD`, and
8
+ * `E2E_USER_PASSWORD` still win over what is set here.
9
+ *
10
+ * The seed signs the accounts up, so the API must promote the administrator:
11
+ * list its address in `ADMIN_EMAILS`. For example, accounts named by the
12
+ * application's own variables:
13
+ *
14
+ * ```ts
15
+ * export const appAccounts: AppAccounts = {
16
+ * admin: { email: process.env.MYAPP_ADMIN_EMAIL, password: process.env.MYAPP_ADMIN_PASSWORD },
17
+ * };
18
+ * ```
19
+ *
20
+ * Never commit a real password here.
21
+ */
22
+ export type AppAccounts = { admin?: Partial<Account>; user?: Partial<Account> };
23
+
24
+ export const appAccounts: AppAccounts = {};
@@ -1,21 +1,51 @@
1
1
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
2
2
 
3
+ /** The `name=value` of the session cookie a sign-in response sets. */
4
+ function sessionCookie(response: Response): string | undefined {
5
+ for (const header of response.headers.getSetCookie()) {
6
+ const [pair = ""] = header.split(";");
7
+ const name = pair.slice(0, pair.indexOf("="));
8
+ // Better Auth names it `better-auth.session_token`, with a `__Secure-`
9
+ // prefix on HTTPS; the multi-session plugin's cookies end in the token.
10
+ if (name.endsWith("session_token")) return pair;
11
+ }
12
+ return undefined;
13
+ }
14
+
15
+ async function signIn(email: string, password: string, cookie?: string): Promise<Response> {
16
+ return fetch(`${base}/api/auth/sign-in/email`, {
17
+ method: "POST",
18
+ headers: { origin: base, "content-type": "application/json", ...(cookie ? { cookie } : {}) },
19
+ body: JSON.stringify({ email, password }),
20
+ });
21
+ }
22
+
3
23
  /**
4
24
  * Signs in `count` times, each as a separate device, and returns the response
5
- * statuses. Each request carries no cookies: a Playwright request context keeps
6
- * the cookies of its earlier sign-ins, and Better Auth's multi-session plugin
7
- * deletes a same-user session that a sign-in request carries, so signing in
8
- * again from one context leaves one session instead of adding one.
25
+ * statuses.
26
+ *
27
+ * Sign-ins are authentication writes. From an anonymous client the rate
28
+ * limiter counts them against the per-address budget that every other spec's
29
+ * sign-ins share, so a hundred of them refused unrelated sign-ins elsewhere in
30
+ * the run. Only the first one is anonymous here; every later one carries the
31
+ * first sign-in's session cookie, so the limiter counts it against that
32
+ * session instead.
33
+ *
34
+ * A request carries that one cookie and nothing else: Better Auth's
35
+ * multi-session plugin deletes a same-user session that a sign-in carries in
36
+ * its multi-session cookies, so a client that kept every cookie would end with
37
+ * one session instead of `count`.
9
38
  */
10
39
  export async function signInOnSeparateDevices(email: string, password: string, count: number): Promise<number[]> {
11
- const statuses: number[] = [];
12
- for (let start = 0; start < count; start += 10) {
40
+ if (count < 1) return [];
41
+ const first = await signIn(email, password);
42
+ await first.body?.cancel();
43
+ const statuses = [first.status];
44
+ const cookie = sessionCookie(first);
45
+ if (first.status !== 200 || !cookie) return statuses;
46
+ for (let start = 1; start < count; start += 10) {
13
47
  const batch = Array.from({ length: Math.min(10, count - start) }, async () => {
14
- const response = await fetch(`${base}/api/auth/sign-in/email`, {
15
- method: "POST",
16
- headers: { origin: base, "content-type": "application/json" },
17
- body: JSON.stringify({ email, password }),
18
- });
48
+ const response = await signIn(email, password, cookie);
19
49
  await response.body?.cancel();
20
50
  return response.status;
21
51
  });
@@ -34,17 +34,19 @@ async function readCreatedUserId(response: CreatedUserResponse): Promise<string>
34
34
 
35
35
  export const test = base.extend<{ disposableUsers: DisposableUsers }>({
36
36
  disposableUsers: async ({ context, playwright }, use, testInfo) => {
37
- // API projects do not inherit the UI project's admin storageState, while a
37
+ // API projects (`api` and its `api-exclusive` twin) do not inherit the UI
38
+ // project's admin storageState, while a
38
39
  // second admin sign-in can invalidate the shared UI session. API tests sign in
39
40
  // an isolated request context. UI tests copy the browser's admin cookies into
40
41
  // their own context once, so a test that later signs its page in as another
41
42
  // user does not turn cleanup into that user's (forbidden) requests.
43
+ const requestOnly = testInfo.project.name.replace(/-exclusive$/, "") === "api";
42
44
  const request = await playwright.request.newContext({
43
45
  baseURL,
44
46
  extraHTTPHeaders: origin,
45
- ...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
47
+ ...(requestOnly ? {} : { storageState: await context.storageState() }),
46
48
  });
47
- if (testInfo.project.name === "api") {
49
+ if (requestOnly) {
48
50
  const signIn = await request.post("/api/auth/sign-in/email", {
49
51
  data: { email: ADMIN.email, password: ADMIN.password },
50
52
  });
@@ -1,5 +1,7 @@
1
1
  // Helpers for the local Mailpit catcher (docker-compose / CI service). Tests that
2
- // exercise email flows skip when it isn't reachable (see MAILPIT_URL).
2
+ // exercise email flows skip when it isn't reachable (see MAILPIT_URL). Each test
3
+ // mails an address of its own and reads the newest message to it; the inbox is
4
+ // shared with tests on other workers, so no test empties it.
3
5
  const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
4
6
 
5
7
  export async function mailpitReachable(): Promise<boolean> {
@@ -10,14 +12,6 @@ export async function mailpitReachable(): Promise<boolean> {
10
12
  }
11
13
  }
12
14
 
13
- export async function clearMailpit(): Promise<void> {
14
- try {
15
- await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
16
- } catch {
17
- /* ignore */
18
- }
19
- }
20
-
21
15
  type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
22
16
  type MailpitMessage = { Text?: string; HTML?: string };
23
17
 
@@ -1,6 +1,8 @@
1
1
  // Read-back helper for the dev SMS sink (the "Mailpit for SMS"). The app posts
2
2
  // outgoing SMS to the sink via SMS_WEBHOOK_URL; tests poll it here to obtain the
3
3
  // delivered code, so the phone-number flow is verified for real (not just "sent").
4
+ // Each test texts a number of its own; the sink is shared with tests on other
5
+ // workers, so no test empties it.
4
6
  const SINK = process.env.SMS_SINK_URL ?? "http://localhost:8095";
5
7
 
6
8
  type SinkMessage = { to: string; body: string; receivedAt: string };
@@ -14,10 +16,6 @@ export async function smsSinkReachable(): Promise<boolean> {
14
16
  }
15
17
  }
16
18
 
17
- export async function clearSms(): Promise<void> {
18
- await fetch(`${SINK}/messages`, { method: "DELETE" }).catch(() => undefined);
19
- }
20
-
21
19
  /** Poll the sink until an SMS to `to` arrives, then return the first 4–8 digit code. */
22
20
  export async function waitForSmsOtp(to: string, timeoutMs = 10_000): Promise<string> {
23
21
  const deadline = Date.now() + timeoutMs;
@@ -1,10 +1,15 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { loadPlaywrightProjects } from "./playwright.extensions";
2
+ import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
5
- // (storageState) which the `ui` project reuses. Serial + single worker because
6
- // tests share one backend/DB. Runs against a live stack on E2E_BASE_URL.
5
+ // (storageState) which the `ui` project reuses, and a `cleanup` project that
6
+ // removes the users the suite created. Runs against a live stack on
7
+ // E2E_BASE_URL. Tests share one backend and database: E2E_WORKERS sets the
8
+ // worker count (default 1), and tests tagged @exclusive, which change state the
9
+ // others rely on, run one at a time in `<project>-exclusive` twins after the
10
+ // others (see playwright.extensions.ts). Each file runs on one worker in order.
7
11
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
12
+ const workers = e2eWorkers();
8
13
  const coreProjects = [
9
14
  { name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
10
15
  { name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
@@ -22,8 +27,9 @@ export default defineConfig({
22
27
  fullyParallel: false,
23
28
  forbidOnly: !!process.env.CI,
24
29
  retries: process.env.CI ? 2 : 0,
25
- workers: 1,
30
+ workers,
31
+ globalSetup: "./playwright.extensions.ts",
26
32
  reporter: [["list"], ["html", { open: "never" }]],
27
33
  use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
28
- projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
34
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
29
35
  });
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
- import { anonState } from "../helpers/accounts";
2
+ import { ADMIN, anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
- import { smsSinkReachable, clearSms, waitForSmsOtp } from "../helpers/sms";
4
+ import { smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
5
5
 
6
6
  // Each test drives a fresh disposable account (via a real UI login) so the shared
7
7
  // admin/user sessions are never mutated.
@@ -31,7 +31,6 @@ test("account: register and verify a phone number", async ({ page, playwright })
31
31
  test.skip((await phoneInput.count()) === 0, "phone number feature disabled");
32
32
 
33
33
  const phone = `+1555${String(Date.now()).slice(-7)}`;
34
- await clearSms();
35
34
  await phoneInput.fill(phone);
36
35
  await page.getByRole("button", { name: "Send code" }).click();
37
36
  const code = await waitForSmsOtp(phone);
@@ -47,9 +46,9 @@ test("account: change email address", async ({ page, playwright }) => {
47
46
  await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
48
47
  });
49
48
 
50
- test("account: delete my own account", async ({ page, playwright }) => {
49
+ test("account: delete my own account", { tag: "@exclusive" }, async ({ page, playwright }) => {
51
50
  const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
52
- await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
51
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
53
52
  try {
54
53
  // Account deletion is a server toggle (off by default) — enable it, then poll
55
54
  // until the capability is live (config store has a short TTL).
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
  import { ADMIN } from "../helpers/accounts";
4
- import { exclusive } from "../helpers/exclusive";
4
+ import { paths } from "../helpers/paths";
5
5
 
6
6
  const PNG = Buffer.from(
7
7
  "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
@@ -17,7 +17,7 @@ test("account opens on the profile section @smoke", async ({ page }) => {
17
17
  await ready(page, "/admin/account");
18
18
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
19
19
  await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
20
- await expect(page.getByLabel("Email")).toHaveValue("admin@example.com");
20
+ await expect(page.getByLabel("Email")).toHaveValue(ADMIN.email);
21
21
  // save is enabled only once the name changes
22
22
  await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
23
23
  await page.getByLabel("Name", { exact: true }).fill(`Admin Preview ${Date.now()}`);
@@ -33,7 +33,7 @@ test("protected app routes do not inherit the admin shell @smoke", async ({ page
33
33
  await expect(page.getByTestId("sidebar-brand")).toBeVisible();
34
34
  });
35
35
 
36
- test("account displays profile image limits and updates every avatar", async ({ page }) => {
36
+ test("account displays profile image limits and updates every avatar", { tag: "@exclusive" }, async ({ page }) => {
37
37
  await ready(page, "/account");
38
38
  await page.request.delete("/api/account/profile-image");
39
39
  await ready(page, "/account");
@@ -49,8 +49,10 @@ test("account displays profile image limits and updates every avatar", async ({
49
49
  await expect(page.getByText(/Profile image updated|프로필 이미지를 변경했습니다/)).toBeVisible();
50
50
  await expect(page.getByTestId("profile-image-preview").locator("img")).toBeVisible();
51
51
 
52
- await ready(page, "/");
53
- await expect(page.getByTestId("account-menu").locator("img")).toBeVisible();
52
+ if (paths.accountMenu !== null) {
53
+ await ready(page, paths.accountMenu);
54
+ await expect(page.getByTestId("account-menu").locator("img")).toBeVisible();
55
+ }
54
56
  await ready(page, "/admin");
55
57
  await expect(page.getByTestId("sidebar-user-menu").locator("img")).toBeVisible();
56
58
 
@@ -81,13 +83,13 @@ test("account rejects invalid profile images before upload", async ({ page }) =>
81
83
  test("account exposes a change-email action when the address is edited", async ({ page }) => {
82
84
  await ready(page, "/admin/account");
83
85
  const email = page.getByLabel("Email");
84
- await expect(email).toHaveValue("admin@example.com");
86
+ await expect(email).toHaveValue(ADMIN.email);
85
87
  // no action until the address actually changes
86
88
  await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
87
- await email.fill("admin+changed@example.com");
89
+ await email.fill(`changed.${ADMIN.email}`);
88
90
  await expect(page.getByRole("button", { name: "Change email" })).toBeVisible();
89
91
  // revert without submitting — don't mutate the shared admin session
90
- await email.fill("admin@example.com");
92
+ await email.fill(ADMIN.email);
91
93
  await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
92
94
  });
93
95
 
@@ -145,7 +147,7 @@ test("account shows a phone field when enabled", async ({ page }) => {
145
147
  await expect(phone).toBeVisible();
146
148
  });
147
149
 
148
- test("admin can update their profile name", async ({ page }) => {
150
+ test("admin can update their profile name", { tag: "@exclusive" }, async ({ page }) => {
149
151
  await ready(page, "/admin/account");
150
152
  const name = page.getByLabel("Name", { exact: true });
151
153
  await name.fill(`Admin Updated ${Date.now()}`);
@@ -230,58 +232,56 @@ test("account nav shows the core sections", async ({ page }) => {
230
232
  await expect(page.getByRole("button", { name: "Sessions" })).toBeVisible();
231
233
  });
232
234
 
233
- test("connected account unlink uses the local account row id @smoke", async ({ page }) => {
235
+ test("connected account unlink uses the local account row id @smoke", { tag: "@exclusive" }, async ({ page }) => {
234
236
  test.slow();
235
- await exclusive("social-providers", async () => {
236
- const provider = "google";
237
- let unlinkBody: unknown;
238
- await page.route("**/api/auth/list-accounts", (route) =>
239
- route.fulfill({
240
- status: 200,
241
- contentType: "application/json",
242
- body: JSON.stringify([
243
- { id: "local-account-id", providerId: provider, accountId: "provider-subject" },
244
- ]),
245
- }),
246
- );
247
- await page.route("**/api/auth/unlink-account", async (route) => {
248
- unlinkBody = route.request().postDataJSON();
249
- await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
250
- });
237
+ const provider = "google";
238
+ let unlinkBody: unknown;
239
+ await page.route("**/api/auth/list-accounts", (route) =>
240
+ route.fulfill({
241
+ status: 200,
242
+ contentType: "application/json",
243
+ body: JSON.stringify([
244
+ { id: "local-account-id", providerId: provider, accountId: "provider-subject" },
245
+ ]),
246
+ }),
247
+ );
248
+ await page.route("**/api/auth/unlink-account", async (route) => {
249
+ unlinkBody = route.request().postDataJSON();
250
+ await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
251
+ });
251
252
 
252
- try {
253
- const enabled = await page.request.put("/api/account/auth-config", {
254
- data: {
255
- social: {
256
- [provider]: {
257
- enabled: true,
258
- clientId: "podokit",
259
- clientSecret: "podokit",
260
- },
253
+ try {
254
+ const enabled = await page.request.put("/api/account/auth-config", {
255
+ data: {
256
+ social: {
257
+ [provider]: {
258
+ enabled: true,
259
+ clientId: "podokit",
260
+ clientSecret: "podokit",
261
261
  },
262
262
  },
263
- });
264
- expect(enabled.ok()).toBeTruthy();
265
- await expect
266
- .poll(async () => {
267
- const response = await page.request.get("/api/account/capabilities");
268
- const body = (await response.json()) as { providers?: string[] };
269
- return body.providers ?? [];
270
- }, { timeout: 8_000 })
271
- .toContain(provider);
263
+ },
264
+ });
265
+ expect(enabled.ok()).toBeTruthy();
266
+ await expect
267
+ .poll(async () => {
268
+ const response = await page.request.get("/api/account/capabilities");
269
+ const body = (await response.json()) as { providers?: string[] };
270
+ return body.providers ?? [];
271
+ }, { timeout: 8_000 })
272
+ .toContain(provider);
272
273
 
273
- await ready(page, "/admin/account");
274
- await page.getByRole("button", { name: "Connected", exact: true }).click();
275
- await page.getByRole("button", { name: "Disconnect", exact: true }).click();
274
+ await ready(page, "/admin/account");
275
+ await page.getByRole("button", { name: "Connected", exact: true }).click();
276
+ await page.getByRole("button", { name: "Disconnect", exact: true }).click();
276
277
 
277
- await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
278
- await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
279
- } finally {
280
- await page.request.put("/api/account/auth-config", {
281
- data: { social: { [provider]: { enabled: false } } },
282
- });
283
- }
284
- });
278
+ await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
279
+ await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
280
+ } finally {
281
+ await page.request.put("/api/account/auth-config", {
282
+ data: { social: { [provider]: { enabled: false } } },
283
+ });
284
+ }
285
285
  });
286
286
 
287
287
  test("two-factor setup shows a scannable QR code when enabled", async ({ page }) => {