Add the account area and account-synced Appearance preferences
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -0,0 +1,178 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_APPEARANCES,
|
||||
ACCOUNT_MOTION_PREFERENCES,
|
||||
ACCOUNT_PREFERENCE_NAMES,
|
||||
AccountStoreError,
|
||||
createAccountStore,
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
ekklesiaCoreUserPreference,
|
||||
parseAccountPreferencesUpdate,
|
||||
resolveAccountPreferences,
|
||||
} from "../src/account/index.js";
|
||||
|
||||
const migrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
|
||||
describe("account preferences model", () => {
|
||||
it("defaults both preferences to following the system", () => {
|
||||
expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" });
|
||||
expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true);
|
||||
});
|
||||
|
||||
it("declares a closed, ordered set of names and values", () => {
|
||||
expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]);
|
||||
expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]);
|
||||
expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]);
|
||||
});
|
||||
|
||||
it.each(
|
||||
ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const),
|
||||
)("accepts appearance %s on its own", (appearance) => {
|
||||
expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance });
|
||||
});
|
||||
|
||||
it.each(
|
||||
ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const),
|
||||
)("accepts motion %s on its own", (motion) => {
|
||||
expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion });
|
||||
});
|
||||
|
||||
it("accepts both preferences together", () => {
|
||||
expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty object", {}],
|
||||
["null", null],
|
||||
["an array", [["appearance", "dark"]]],
|
||||
["a string", "dark"],
|
||||
["an unknown appearance", { appearance: "sepia" }],
|
||||
["an unknown motion", { motion: "none" }],
|
||||
["a differently cased value", { appearance: "Dark" }],
|
||||
["a non-string value", { appearance: 1 }],
|
||||
["a null value", { motion: null }],
|
||||
["an unknown key beside a valid one", { appearance: "dark", radius: "large" }],
|
||||
["an unknown key alone", { theme: "dark" }],
|
||||
["a user id", { appearance: "dark", userId: "someone-else" }],
|
||||
["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')],
|
||||
[
|
||||
"a class instance",
|
||||
new (class Preferences {
|
||||
appearance = "dark";
|
||||
})(),
|
||||
],
|
||||
])("refuses %s whole", (_label, value) => {
|
||||
expect(parseAccountPreferencesUpdate(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => {
|
||||
expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual(
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
);
|
||||
expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "system",
|
||||
});
|
||||
// A value the constraint should have made impossible is never passed through.
|
||||
expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({
|
||||
appearance: "system",
|
||||
motion: "allow",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account preference storage", () => {
|
||||
it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => {
|
||||
const config = getTableConfig(ekklesiaCoreUserPreference);
|
||||
expect(config.name).toBe("ekklesia_core_user_preference");
|
||||
expect(config.columns.map((column) => column.name).sort()).toEqual([
|
||||
"appearance",
|
||||
"created_at",
|
||||
"motion",
|
||||
"updated_at",
|
||||
"user_id",
|
||||
]);
|
||||
const userId = config.columns.find((column) => column.name === "user_id");
|
||||
expect(userId?.primary).toBe(true);
|
||||
// Preference columns are nullable: `null` is "never chosen", resolved to the default on read.
|
||||
for (const name of ACCOUNT_PREFERENCE_NAMES) {
|
||||
expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false);
|
||||
}
|
||||
expect(config.foreignKeys).toHaveLength(1);
|
||||
expect(config.foreignKeys[0]?.onDelete).toBe("cascade");
|
||||
});
|
||||
|
||||
it("constrains each column in the migration to exactly the model's values", async () => {
|
||||
const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8");
|
||||
const allowed = (column: string): string[] => {
|
||||
const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql);
|
||||
return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", ""));
|
||||
};
|
||||
expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]);
|
||||
expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]);
|
||||
// No JSON, no key/value pair, nothing a client could fill with anything.
|
||||
expect(sql).not.toMatch(/jsonb?|hstore/iu);
|
||||
});
|
||||
|
||||
it("is a migration that destroys nothing", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
|
||||
) as { entries: { tag: string; destructive: { mode: string } }[] };
|
||||
expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({
|
||||
destructive: { mode: "none" },
|
||||
tag: "0008_user_preferences",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account store validation", () => {
|
||||
it("refuses an invalid update before touching the database", async () => {
|
||||
const untouchable = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("the database must not be reached");
|
||||
},
|
||||
},
|
||||
);
|
||||
const store = createAccountStore(untouchable as never);
|
||||
await expect(
|
||||
store.updatePreferences("user-1" as never, { appearance: "sepia" } as never),
|
||||
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" });
|
||||
await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf(
|
||||
AccountStoreError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a database failure without the underlying error", async () => {
|
||||
const failing = {
|
||||
select() {
|
||||
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
|
||||
},
|
||||
transaction() {
|
||||
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
|
||||
},
|
||||
};
|
||||
const store = createAccountStore(failing as never);
|
||||
const read = await store.readAccount("user-1" as never).catch((error: unknown) => error);
|
||||
expect(read).toBeInstanceOf(AccountStoreError);
|
||||
expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" });
|
||||
expect(String((read as Error).stack)).not.toContain("secret");
|
||||
const write = await store
|
||||
.updatePreferences("user-1" as never, { appearance: "dark" })
|
||||
.catch((error: unknown) => error);
|
||||
expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" });
|
||||
expect(String((write as Error).message)).not.toContain("ECONNREFUSED");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user