Files
clean-architecture-frontend…/tests/browser-capabilities/local-draft-composition.spec.ts
T

245 lines
7.8 KiB
TypeScript

import type {
IndexedDbRuntimeDependencies,
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
import type { LocalDraft } from "../../src/features/local-draft-feature/domain/local-draft.ts";
import {
expect,
test,
} from "../support/browser/strict-browser-test.ts";
test("composes the local-draft feature over the real browser IndexedDB runtime", async ({
page,
}) => {
await page.route("**/favicon.ico", (route) =>
route.fulfill({ status: 204 }),
);
await page.goto("/config.json");
const result = await page.evaluate(async () => {
const indexedDbModulePath =
"/src/adapters/storage/indexeddb/index.ts";
const contributionModulePath =
"/src/features/feature-adapter-contribution.ts";
const localDraftModulePath =
"/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
const {
createIndexedDbRuntime,
indexedDbPhysicalDatabaseName,
} = (await import(
/* @vite-ignore */ indexedDbModulePath
)) as typeof import("../../src/adapters/storage/indexeddb/index.ts");
const {
composeFeatureAdapterInputs,
createIndexedDbRepositoryProvider,
} = (await import(
/* @vite-ignore */ contributionModulePath
)) as typeof import("../../src/features/feature-adapter-contribution.ts");
const {
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
} = (await import(
/* @vite-ignore */ localDraftModulePath
)) as typeof import("../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts");
const suffix = crypto.randomUUID().replaceAll("-", "");
const scope = Object.freeze({
authorityToken: `a${suffix.slice(0, 31)}`,
namespaceToken: `n${suffix.slice(1, 32)}`,
partitionToken: `p${[...suffix].reverse().join("").slice(0, 31)}`,
accountScope: "ORIGIN_SHARED" as const,
});
const storagePolicy = Object.freeze({
owner: "local-draft-feature",
namespace: "local-draft",
classification: "INTERNAL" as const,
authority: "LOCAL_FIRST" as const,
accountScope: "ORIGIN_SHARED" as const,
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
softBudgetBytes: 1_000_000,
hardBudgetBytes: 2_000_000,
evictionPriority: "USER_AUTHORED" as const,
logoutAction: "KEEP_ORIGIN_SHARED" as const,
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
pressureAction: "RETAIN" as const,
unavailableFallback: "READ_ONLY" as const,
});
const config: IndexedDbRuntimeDependencies<
LocalDraft,
LocalDraft,
never
> = {
scope,
storagePolicy,
schemaVersion: 1,
recordStore: "records",
governanceStore: "governance",
retentionStore: "retention",
retentionEligibilityIndex: "by-eligibility",
lifecycleMetadataStores: [],
idempotencyStore: "receipts",
idempotencyExpiryIndex: "by-expiry",
receiptRetentionMs: 60_000,
maxIdempotencyReceipts: 100,
migrations: [
{
id: "local-draft-schema-v1",
fromVersion: 0,
toVersion: 1,
operations: [
{
kind: "CREATE_STORE",
name: "governance",
keyPath: "bindingKey",
},
{
kind: "CREATE_STORE",
name: "retention",
keyPath: "recordKey",
indexes: [
{
name: "by-eligibility",
keyPath: "eligibleAtEpochMs",
},
],
},
{
kind: "CREATE_STORE",
name: "records",
keyPath: "key",
},
{
kind: "CREATE_STORE",
name: "receipts",
keyPath: "idempotencyKey",
indexes: [
{
name: "by-expiry",
keyPath: "expiresAtEpochMs",
},
],
},
],
},
],
codec: {
currentVersion: 1,
encode: (value) => ({ ok: true, value }),
measureStoredBytes: (value) =>
new TextEncoder().encode(JSON.stringify(value)).byteLength,
decode: (version, value) => {
if (
version !== 1 ||
value === null ||
typeof value !== "object"
) {
return { ok: false };
}
const candidate = value as Partial<LocalDraft>;
return typeof candidate.draftId === "string" &&
typeof candidate.title === "string" &&
typeof candidate.body === "string"
? { ok: true, value: candidate as LocalDraft }
: { ok: false };
},
fingerprint: async (value) => {
const bytes = new TextEncoder().encode(
JSON.stringify([value.draftId, value.title, value.body]),
);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
},
},
queryPolicy: {
plan: () => ({ limit: 1 }),
},
authorizeLifecycle: () => ({
authorized: true,
proofToken: "localdraftauthorityproof_001",
}),
};
const runtime = createIndexedDbRuntime(config);
const databaseName = indexedDbPhysicalDatabaseName(scope);
const opened = await runtime.open();
try {
const indexedDb = createIndexedDbRepositoryProvider(
Object.freeze({ "local-draft": runtime }),
);
const composed = composeFeatureAdapterInputs(
Object.freeze([LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION] as const),
Object.freeze(["local-draft"]),
Object.freeze({ indexedDb }),
);
const localDraft = composed["local-draft"];
if (!localDraft) {
throw new Error("local-draft feature input was not composed");
}
const draft = Object.freeze({
draftId: "draft-1",
title: "Architecture notes",
body: "Stored through the reusable IndexedDB capability.",
});
const saved = await localDraft.saveDraft({
draft,
expectedRevision: null,
idempotencyKey: "local-draft-create-1",
});
const found = await localDraft.findDraft(draft.draftId);
const removed = await localDraft.removeDraft({
draftId: draft.draftId,
expectedRevision: saved.ok ? saved.value.revision : 1,
idempotencyKey: "local-draft-remove-1",
});
const afterRemove = await localDraft.findDraft(draft.draftId);
return {
opened,
saved,
found,
removed,
afterRemove,
composedFeatureIds: Object.keys(composed),
runtimeStatus: runtime.getStatus(),
};
} finally {
runtime.close();
await new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(databaseName);
request.onsuccess = () => resolve();
request.onerror = () =>
reject(request.error ?? new Error("IndexedDB cleanup failed"));
request.onblocked = () =>
reject(new Error("IndexedDB cleanup was blocked"));
});
}
});
expect(result.opened).toEqual({ ok: true, value: undefined });
expect(result.composedFeatureIds).toEqual(["local-draft"]);
expect(result.saved).toEqual({ ok: true, value: { revision: 1 } });
expect(result.found).toMatchObject({
ok: true,
value: {
draft: {
draftId: "draft-1",
title: "Architecture notes",
body: "Stored through the reusable IndexedDB capability.",
},
revision: 1,
},
});
expect(result.removed).toEqual({ ok: true, value: undefined });
expect(result.afterRemove).toEqual({ ok: true, value: null });
expect(result.runtimeStatus).toMatchObject({
kind: "READY",
schemaVersion: 1,
});
});