alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
295 lines (251 loc) • 9.71 kB
text/typescript
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" });
});
});