Skip to content

Commit 82ac698

Browse files
committed
feat(new-deepnotes): Vitest session MSW contracts and useSession tests, and plan progress
1 parent f72da8c commit 82ac698

5 files changed

Lines changed: 357 additions & 7 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Checklist for [docs/RESTART_PLAN.md](../docs/RESTART_PLAN.md). **Procedure-level
1414
| **1** — Legacy repo hygiene | **Optional** | Only if still editing the old monorepo. |
1515
| **2** — Repo bootstrap | **Done** | Turbo, CI, template DB tests, deploy notes. |
1616
| **3** — REST + Drizzle | **In progress** | All **HTTP** items in TRPC_REST_MAP through **slice 10** (`…/collab-updates`), **Stripe**, **2FA**, membership, crypto bootstrap routes. **Not started:** **live Yjs collab WebSocket** and **realtime** (legacy `realtime-server` / msgpackr — separate from collab). |
17-
| **4** — Client MVP | **In progress** | Auth, register (E2EE), pages list, Tiptap+Yjs+REST collab, groups + invites/join E2EE, notifications (list/read; **no** body decrypt), MSW contract smoke, ESLint import walls, **`apps/marketing`** (`vite-ssg`). **Done:** Playwright smoke for **demo** session (httpOnly tokens, `loggedIn` hint, reload → bootstrap/refresh). **Next:** password-login E2E (registered user fixture or flow); optional demo keyring assertions. |
17+
| **4** — Client MVP | **In progress** | Same as Phase 4 checklist; **added** MSW + Vitest session POST contracts and `useSession` unit matrix. **Done:** Playwright demo session E2E; **Next:** optional password-login Playwright or Phase 3 collab/realtime WS MVP. |
1818
| **5** — Cutover | **Not started** | Canary, retire `/trpc` when safe. |
1919

2020
---
@@ -62,6 +62,7 @@ Cross-check [TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md): **sessions**, **users.a
6262
- [x] `/groups`, `/groups/:id`, invite landing `/invite`, join `/join`, membership crypto
6363
- [x] `/notifications` (ciphertext not decrypted in UI)
6464
- [x] MSW: `GET /api/health`, `GET /api/users/me`; ESLint `no-restricted-imports` on `src/**/*.ts`
65+
- [x] MSW + Vitest: session POST routes (`login`, `demo`, `refresh`, `logout`) + `useSession` flows (password login, 2FA flag, demo, logout, bootstrap)
6566
- [x] Playwright: demo session against real `wrangler dev` + Vite (see **E2E / Playwright** below)
6667
- [x] Marketing landing (`vite-ssg`): `apps/marketing`
6768
- [ ] Password-login (or registered-user) Playwright path; optional/crypto assertions beyond cookies
@@ -91,11 +92,11 @@ Cross-check [TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md): **sessions**, **users.a
9192

9293
| Package | What runs | Notes |
9394
|---------|-----------|-------|
94-
| `@deepnotes/session` | **`account-flows.integration.test.ts`****20** `it()` when `DATABASE_URL` + admin URL set; clones template DB | Account, 2FA, groups/pages, prefs, slices 4–10, membership, collab REST |
95+
| `@deepnotes/session` | **`account-flows.integration.test.ts`****24** `it()` when `DATABASE_URL` + admin URL set; clones template DB | Account, 2FA, groups/pages, prefs, slices 4–10, membership, collab REST |
9596
| `@deepnotes/db` | `template-db.test.ts`**6** FK/clone cases | |
9697
| `@deepnotes/api` | `openapi.test.ts`, `schemas/users.test.ts`, … | |
9798
| `@deepnotes/api-worker` | **`index.test.ts`****71** route × 503 matrix + **1** extra `email-verification/confirm` 503 | Totals **72** env-missing smoke cases |
98-
| `@deepnotes/web` | Vitest + happy-dom; composable/API tests | MSW contracts; extend login matrix |
99+
| `@deepnotes/web` | Vitest + happy-dom; MSW + `useSession` tests | **34** unit smoke cases (incl. session routes + `useSession`) |
99100
| `@deepnotes/marketing` | `vite-ssg build``dist/` | Static landing; `VITE_WEB_APP_URL` for app link |
100101
| **Playwright** | `pnpm test:e2e` (root) → `@deepnotes/web` **1** spec (demo cookies + reload) | Needs migrated DB + `.dev.vars`; CI installs Chromium only |
101102

@@ -124,8 +125,9 @@ Run session integration (from repo root):
124125

125126
| Date | Change |
126127
|------|--------|
128+
| 2026-04-29 | MSW handlers + contract tests for session POST routes; Vitest `useSession` matrix (`resetSessionSingletonForTests`); plan progress (+ session integration `it()` count). |
127129
| 2026-04-29 | `apps/marketing` (`vite-ssg` single-page), `docs/DRIZZLE_MIGRATIONS.md`, deploy doc row for marketing + `VITE_WEB_APP_URL`; plan progress. |
128-
| 2026-04-27 | Compacted PLAN_PROGRESS; verified legacy map vs `TRPC_REST_MAP` — HTTP + listed WS flows migrated; added **realtime** vs **collab WS** distinction, optional gaps (public page list, vite-ssg, scheduler). Corrected counts: **20** session integration `it()`, **72** worker 503 smokes (71-route matrix + confirm). |
130+
| 2026-04-27 | Compacted PLAN_PROGRESS; verified legacy map vs `TRPC_REST_MAP` — HTTP + listed WS flows migrated; added **realtime** vs **collab WS** distinction, optional gaps (public page list, vite-ssg, scheduler). Corrected counts: session integration `it()` tally and **72** worker 503 smokes (71-route matrix + confirm). |
129131
| 2026-04-27 | MSW contract tests + ESLint restricted imports on web `*.ts`. |
130132
| 2026-04-27 | Tiptap + Yjs editor; invite/join E2EE + crypto bootstrap API; members detail UI; notifications thin SPA; groups overview; collab REST + `passwordSalt`; Stripe. |
131133

new-deepnotes/apps/web/src/api/client.contract.test.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import { http, HttpResponse } from "msw";
2-
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2+
import {
3+
afterAll,
4+
afterEach,
5+
beforeAll,
6+
beforeEach,
7+
describe,
8+
expect,
9+
it,
10+
} from "vitest";
311

4-
import { contractApiBaseUrl } from "../test/msw/deepnotes-handlers";
12+
import {
13+
contractApiBaseUrl,
14+
deepnotesSessionContractHandlers,
15+
mswSessionLoginSuccess,
16+
} from "../test/msw/deepnotes-handlers";
517
import { mswServer } from "../test/msw/node";
618
import { createDeepnotesApiClient } from "./client";
719

@@ -61,4 +73,99 @@ describe("createDeepnotesApiClient (MSW contract)", () => {
6173
message: "Not logged in.",
6274
});
6375
});
76+
77+
describe("session POST routes", () => {
78+
beforeEach(() => {
79+
mswServer.use(...deepnotesSessionContractHandlers());
80+
});
81+
82+
it("POST /api/sessions/logout returns 204 with empty body", async () => {
83+
const client = createDeepnotesApiClient(contractApiBaseUrl);
84+
const { data, error, response } = await client.POST(
85+
"/api/sessions/logout",
86+
{},
87+
);
88+
expect(response.status).toBe(204);
89+
expect(error).toBeUndefined();
90+
expect(data).toBeUndefined();
91+
});
92+
93+
it("POST /api/sessions/refresh returns 200 and SessionRefreshSuccess", async () => {
94+
const client = createDeepnotesApiClient(contractApiBaseUrl);
95+
const { data, error, response } = await client.POST(
96+
"/api/sessions/refresh",
97+
{},
98+
);
99+
expect(response.status).toBe(200);
100+
expect(error).toBeUndefined();
101+
expect(data).toMatchObject({
102+
oldSessionKey: "dGVzdA==",
103+
newSessionKey: "dGVzdGI=",
104+
});
105+
});
106+
107+
it("POST /api/sessions/demo returns 200 and SessionLoginSuccess", async () => {
108+
const client = createDeepnotesApiClient(contractApiBaseUrl);
109+
const body = await import("../features/auth/build-demo-session").then((m) =>
110+
m.buildSessionDemoRequest(),
111+
);
112+
const { data, error, response } = await client.POST("/api/sessions/demo", {
113+
body,
114+
});
115+
expect(response.status).toBe(200);
116+
expect(error).toBeUndefined();
117+
expect(data).toMatchObject({
118+
...mswSessionLoginSuccess,
119+
});
120+
});
121+
122+
it("POST /api/sessions/login returns 200 and SessionLoginSuccess", async () => {
123+
const client = createDeepnotesApiClient(contractApiBaseUrl);
124+
const { loginPreimageFromPassword, uint8ToBase64 } = await import(
125+
"../features/auth/bytes"
126+
);
127+
const body = {
128+
email: "a@example.com",
129+
loginHash: uint8ToBase64(loginPreimageFromPassword("pw")),
130+
rememberSession: false,
131+
};
132+
const { data, error, response } = await client.POST("/api/sessions/login", {
133+
body,
134+
});
135+
expect(response.status).toBe(200);
136+
expect(error).toBeUndefined();
137+
expect(data).toMatchObject({ ...mswSessionLoginSuccess });
138+
});
139+
140+
it("POST /api/sessions/login maps 401 + SessionErrorResponse (2FA)", async () => {
141+
mswServer.use(
142+
http.post(`${contractApiBaseUrl}/api/sessions/login`, () =>
143+
HttpResponse.json(
144+
{
145+
code: "UNAUTHORIZED",
146+
message: "Requires two-factor authentication.",
147+
},
148+
{ status: 401 },
149+
),
150+
),
151+
);
152+
const client = createDeepnotesApiClient(contractApiBaseUrl);
153+
const { loginPreimageFromPassword, uint8ToBase64 } = await import(
154+
"../features/auth/bytes"
155+
);
156+
const { data, error, response } = await client.POST("/api/sessions/login", {
157+
body: {
158+
email: "a@example.com",
159+
loginHash: uint8ToBase64(loginPreimageFromPassword("pw")),
160+
rememberSession: true,
161+
},
162+
});
163+
expect(response.status).toBe(401);
164+
expect(data).toBeUndefined();
165+
expect(error).toMatchObject({
166+
code: "UNAUTHORIZED",
167+
message: "Requires two-factor authentication.",
168+
});
169+
});
170+
});
64171
});
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const stubB64 = "dGVzdA==";
4+
const stubB64b = "dGVzdGI=";
5+
6+
const userMe = {
7+
userId: "u_test",
8+
emailVerified: true,
9+
demo: false,
10+
personalGroupId: "g_test",
11+
};
12+
13+
const loginSuccessNoSalt = {
14+
userId: "u_test",
15+
sessionId: "ses_test",
16+
sessionKey: stubB64,
17+
personalGroupId: "g_test",
18+
publicKeyring: stubB64,
19+
encryptedPrivateKeyring: stubB64,
20+
encryptedSymmetricKeyring: stubB64,
21+
};
22+
23+
const { mockGet, mockPost } = vi.hoisted(() => ({
24+
mockGet: vi.fn(),
25+
mockPost: vi.fn(),
26+
}));
27+
28+
vi.mock("../../api/client", () => ({
29+
createDeepnotesApiClient: vi.fn(() => ({
30+
GET: mockGet,
31+
POST: mockPost,
32+
})),
33+
}));
34+
35+
import {
36+
resetSessionSingletonForTests,
37+
TWO_FACTOR_MESSAGE,
38+
useSession,
39+
} from "./useSession";
40+
41+
function okFetch<T>(status: number, data: T) {
42+
return Promise.resolve({
43+
data,
44+
error: undefined,
45+
response: { status } as Pick<Response, "status"> as Response,
46+
});
47+
}
48+
49+
describe("useSession", () => {
50+
beforeEach(() => {
51+
resetSessionSingletonForTests();
52+
mockGet.mockReset();
53+
mockPost.mockReset();
54+
document.cookie = "";
55+
});
56+
57+
it("loginWithPassword succeeds and loads /me (no passwordSalt skips keyring unwrap)", async () => {
58+
mockPost.mockImplementation((path: string) => {
59+
if (path === "/api/sessions/login") {
60+
return okFetch(200, loginSuccessNoSalt);
61+
}
62+
return okFetch(404, undefined as never);
63+
});
64+
mockGet.mockImplementation((path: string) => {
65+
if (path === "/api/users/me") return okFetch(200, userMe);
66+
return okFetch(404, undefined as never);
67+
});
68+
69+
const { loginWithPassword, user, lastError } = useSession();
70+
const result = await loginWithPassword({
71+
email: "user@Example.com ",
72+
password: "secret",
73+
rememberSession: true,
74+
});
75+
76+
expect(result).toEqual({ ok: true, needTwoFactor: false });
77+
expect(lastError.value).toBe(null);
78+
expect(user.value?.userId).toBe("u_test");
79+
expect(mockPost.mock.calls.some((c) => c[0] === "/api/sessions/login")).toBe(true);
80+
const loginBody = mockPost.mock.calls.find((c) => c[0] === "/api/sessions/login")?.[1] as {
81+
body: { email: string };
82+
};
83+
expect(loginBody.body.email).toBe("user@example.com");
84+
});
85+
86+
it("loginWithPassword returns needTwoFactor on 401 with server 2FA message", async () => {
87+
mockPost.mockImplementation((path: string) => {
88+
if (path === "/api/sessions/login") {
89+
return Promise.resolve({
90+
data: undefined,
91+
error: {
92+
code: "UNAUTHORIZED",
93+
message: TWO_FACTOR_MESSAGE,
94+
},
95+
response: { status: 401 } as Pick<Response, "status"> as Response,
96+
});
97+
}
98+
return okFetch(404, undefined as never);
99+
});
100+
101+
const { loginWithPassword, lastError, twoFactorRequired } = useSession();
102+
const result = await loginWithPassword({
103+
email: "a@b.co",
104+
password: "x",
105+
rememberSession: false,
106+
});
107+
108+
expect(result).toEqual({ ok: false, needTwoFactor: true });
109+
expect(twoFactorRequired.value).toBe(true);
110+
expect(lastError.value).toBe(TWO_FACTOR_MESSAGE);
111+
});
112+
113+
it("loginWithDemo calls demo then loads /me", async () => {
114+
mockPost.mockImplementation((path: string) => {
115+
if (path === "/api/sessions/demo") {
116+
return okFetch(200, loginSuccessNoSalt);
117+
}
118+
return okFetch(404, undefined as never);
119+
});
120+
mockGet.mockImplementation((path: string) => {
121+
if (path === "/api/users/me") return okFetch(200, { ...userMe, demo: true });
122+
return okFetch(404, undefined as never);
123+
});
124+
125+
const { loginWithDemo, user } = useSession();
126+
const { ok } = await loginWithDemo();
127+
expect(ok).toBe(true);
128+
expect(user.value?.demo).toBe(true);
129+
});
130+
131+
it("logout clears user on 204", async () => {
132+
mockPost.mockImplementation((path: string) => {
133+
if (path === "/api/sessions/login") {
134+
return okFetch(200, loginSuccessNoSalt);
135+
}
136+
if (path === "/api/sessions/logout") {
137+
return Promise.resolve({
138+
data: undefined,
139+
error: undefined,
140+
response: { status: 204 } as Pick<Response, "status"> as Response,
141+
});
142+
}
143+
return okFetch(404, undefined as never);
144+
});
145+
mockGet.mockImplementation((path: string) => {
146+
if (path === "/api/users/me") return okFetch(200, userMe);
147+
return okFetch(404, undefined as never);
148+
});
149+
150+
const { loginWithPassword, logout, user } = useSession();
151+
await loginWithPassword({
152+
email: "z@z.co",
153+
password: "pw",
154+
rememberSession: false,
155+
});
156+
expect(user.value).not.toBe(null);
157+
158+
await logout();
159+
expect(user.value).toBe(null);
160+
});
161+
162+
it("bootstrap does not call refresh when loggedIn cookie is absent", async () => {
163+
const { bootstrap, bootstrapped, user } = useSession();
164+
await bootstrap();
165+
expect(mockPost.mock.calls.map((c) => c[0])).not.toContain("/api/sessions/refresh");
166+
expect(user.value).toBe(null);
167+
expect(bootstrapped.value).toBe(true);
168+
});
169+
170+
it("bootstrap refreshes then loads /me when loggedIn cookie is set", async () => {
171+
document.cookie = "loggedIn=true";
172+
173+
mockPost.mockImplementation((path: string) => {
174+
if (path === "/api/sessions/refresh") {
175+
return okFetch(200, { oldSessionKey: stubB64, newSessionKey: stubB64b });
176+
}
177+
return okFetch(404, undefined as never);
178+
});
179+
mockGet.mockImplementation((path: string) => {
180+
if (path === "/api/users/me") return okFetch(200, userMe);
181+
return okFetch(404, undefined as never);
182+
});
183+
184+
const { bootstrap, user } = useSession();
185+
await bootstrap();
186+
187+
expect(mockPost.mock.calls.map((c) => c[0])).toContain("/api/sessions/refresh");
188+
expect(user.value?.userId).toBe("u_test");
189+
});
190+
});

new-deepnotes/apps/web/src/features/auth/useSession.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ function setErrorFromBody(body: unknown, fallback: string) {
3939
lastError.value = fallback;
4040
}
4141

42+
/**
43+
* Resets {@link useSession} module state between Vitest cases (singleton refs).
44+
*/
45+
export function resetSessionSingletonForTests(): void {
46+
user.value = null;
47+
loading.value = false;
48+
bootstrapped.value = false;
49+
lastError.value = null;
50+
twoFactorRequired.value = false;
51+
bootstrapInFlight = null;
52+
}
53+
4254
export function useSession() {
4355
const isAuthenticated = computed(() => user.value != null);
4456
const loggedInHint = computed(

0 commit comments

Comments
 (0)