UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

295 lines (251 loc) 9.71 kB
import { $atom, $inject, $module, $state, Alepha, z } from "alepha"; import { $action, AlephaServer } from "alepha/server"; import { describe, expect, it } from "vitest"; import { AlephaServerCookies } from "../index.ts"; const settingsAtom = $atom({ name: "test.cookie.settings", schema: z.object({ theme: z.string() }), default: { theme: "light" }, persist: "cookie", }); class CookieAtomApp { protected readonly alepha = $inject(Alepha); /** * Registers the atom at configure time so the adapter tracks it before * the first request. */ settings = $state(settingsAtom); read = $action({ schema: { response: z.object({ theme: z.string() }), }, handler: () => ({ theme: this.settings.theme }), }); update = $action({ schema: { body: z.object({ theme: z.string() }), response: z.object({ ok: z.boolean() }), }, handler: ({ body }) => { this.alepha.store.set(settingsAtom, { theme: body.theme }); return { ok: true }; }, }); } const alepha = Alepha.create().with(AlephaServer).with(AlephaServerCookies); const app = alepha.inject(CookieAtomApp); describe("AtomCookiePersistence", () => { it("writes a Set-Cookie when a persisted atom mutates during a request", async () => { const response = await app.update.fetch({ body: { theme: "dark" } }); const setCookie = response.raw?.headers.get("set-cookie"); expect(setCookie).toBeDefined(); const value = JSON.parse( decodeURIComponent( setCookie!.match(/test\.cookie\.settings=([^;]*)/)![1], ), ); expect(value).toEqual({ theme: "dark" }); }); it("seeds request-scoped state from the incoming cookie", async () => { const cookieHeader = `test.cookie.settings=${encodeURIComponent( JSON.stringify({ theme: "dark" }), )}`; const response = await app.read.fetch( {}, { request: { headers: { cookie: cookieHeader } } }, ); expect(response.data.theme).toBe("dark"); }); it("falls back to the default without a cookie", async () => { const response = await app.read.fetch( {}, { request: { headers: { cookie: "" } } }, ); expect(response.data.theme).toBe("light"); }); }); describe("registration order: atoms declared on a $module, before the adapter exists", () => { // The documented usage shape — and the one that used to make // `persist: "cookie"` a silent no-op in BOTH directions. // // `$module.register()` registers `options.atoms` FIRST, then wires // `imports[]`, then injects `services[]`. So by the time // `AtomCookiePersistence` is instantiated (via the `AlephaServerCookies` // import), the atom has already registered and its one-shot // `state:register` event has already been emitted and dropped — // `EventManager` has no replay buffer. An adapter that discovered atoms // from that event kept an empty map forever: no cookie read on request, no // Set-Cookie on mutation, no warning. // // A test that injects the adapter before touching the atom proves nothing; // it just reproduces the fragile ordering that happened to work. const modulePrefs = $atom({ name: "test.cookie.module.prefs", schema: z.object({ theme: z.string() }), default: { theme: "light" }, persist: "cookie", }); class ModuleCookieApp { protected readonly alepha = $inject(Alepha); read = $action({ schema: { response: z.object({ theme: z.string() }), }, handler: () => ({ theme: this.alepha.store.get(modulePrefs).theme }), }); update = $action({ schema: { body: z.object({ theme: z.string() }), response: z.object({ ok: z.boolean() }), }, handler: ({ body }) => { this.alepha.store.set(modulePrefs, { theme: body.theme }); return { ok: true }; }, }); } const CookieAtomModule = $module({ name: "test.cookie.module", atoms: [modulePrefs], imports: [AlephaServerCookies], services: [ModuleCookieApp], }); const moduleAlepha = Alepha.create().with(CookieAtomModule); const moduleApp = moduleAlepha.inject(ModuleCookieApp); it("seeds request state from the incoming cookie", async () => { const cookieHeader = `test.cookie.module.prefs=${encodeURIComponent( JSON.stringify({ theme: "dark" }), )}`; const response = await moduleApp.read.fetch( {}, { key: "module-read", request: { headers: { cookie: cookieHeader } }, }, ); expect(response.data.theme).toBe("dark"); }); it("emits a Set-Cookie when the atom mutates", async () => { const response = await moduleApp.update.fetch({ body: { theme: "dark" } }); const setCookie = response.raw?.headers.get("set-cookie"); expect(setCookie).toBeDefined(); expect(setCookie).not.toBeNull(); const value = JSON.parse( decodeURIComponent( setCookie!.match(/test\.cookie\.module\.prefs=([^;]*)/)![1], ), ); expect(value).toEqual({ theme: "dark" }); }); it("falls back to the default without a cookie", async () => { const response = await moduleApp.read.fetch( {}, { key: "module-read-none", request: { headers: { cookie: "" } } }, ); expect(response.data.theme).toBe("light"); }); }); describe("cookie-name symmetry with APP_NAME set", () => { // ServerCookiesProvider.getCookie/setCookie normally namespace every // cookie under `${APP_NAME.toLowerCase()}.${name}`. The browser variant // of this adapter (AtomCookiePersistence.browser.ts) can never see // APP_NAME — it is not baked into the client bundle and not part of SSR // hydration — so it always reads/writes the BARE atom key. Without the // `prefix: false` override in AtomCookiePersistence.cookieOptions(), a // client-side mutation would write `theme`, the server would look for // `rdm.theme`, miss, and SSR the default — flash of wrong state on every // reload. These tests must run with APP_NAME set: without it, both sides // collapse to the bare key and the asymmetry is invisible. const appNameSettingsAtom = $atom({ name: "test.cookie.appname", schema: z.object({ theme: z.string() }), default: { theme: "light" }, persist: "cookie", }); class AppNameCookieAtomApp { protected readonly alepha = $inject(Alepha); settings = $state(appNameSettingsAtom); read = $action({ schema: { response: z.object({ theme: z.string() }), }, handler: () => ({ theme: this.settings.theme }), }); update = $action({ schema: { body: z.object({ theme: z.string() }), response: z.object({ ok: z.boolean() }), }, handler: ({ body }) => { this.alepha.store.set(appNameSettingsAtom, { theme: body.theme }); return { ok: true }; }, }); } const appNameAlepha = Alepha.create({ env: { APP_NAME: "RDM" } }) .with(AlephaServer) .with(AlephaServerCookies); const appNameApp = appNameAlepha.inject(AppNameCookieAtomApp); it("writes the bare (unprefixed) cookie name — the same name the browser variant writes", async () => { const response = await appNameApp.update.fetch({ body: { theme: "dark" }, }); const setCookie = response.raw?.headers.get("set-cookie"); expect(setCookie).toBeDefined(); expect(setCookie).toMatch(/^test\.cookie\.appname=/); // The bug this guards against: the namespaced name showing up instead. expect(setCookie).not.toMatch(/rdm\.test\.cookie\.appname/); }); it("reads back a bare-named incoming cookie — the same name the browser variant reads", async () => { const cookieHeader = `test.cookie.appname=${encodeURIComponent( JSON.stringify({ theme: "dark" }), )}`; const response = await appNameApp.read.fetch( {}, { request: { headers: { cookie: cookieHeader } } }, ); expect(response.data.theme).toBe("dark"); }); }); describe("request isolation (cookie-seeded state never leaks across concurrent requests)", () => { // Permanent regression coverage for the invariant confirmed during // review: cookie-seeded atom state stays fork-local (each request runs // inside its own AsyncLocalStorage fork — see ServerRouterProvider / // AlsProvider), and the app-level store never sees a request's value. // GET requests dedupe by `{url, method}` in HttpClient (ignoring // headers), so each concurrent call below needs a distinct `key` to // actually hit the server three times instead of sharing one response. it("keeps concurrent requests' cookie-seeded values isolated, and never touches the app-level store", async () => { const cookieFor = (theme: string) => `test.cookie.settings=${encodeURIComponent(JSON.stringify({ theme }))}`; const [alice, bob, none] = await Promise.all([ app.read.fetch( {}, { key: "isolation-alice", request: { headers: { cookie: cookieFor("alice") } }, }, ), app.read.fetch( {}, { key: "isolation-bob", request: { headers: { cookie: cookieFor("bob") } }, }, ), app.read.fetch( {}, { key: "isolation-none", request: { headers: { cookie: "" } }, }, ), ]); expect(alice.data.theme).toBe("alice"); expect(bob.data.theme).toBe("bob"); expect(none.data.theme).toBe("light"); // default, not a leaked neighbor value // The root (app-level) store must never have been mutated by a // request-scoped cookie read. expect(alepha.store.get(settingsAtom, "app")).toEqual({ theme: "light" }); }); });