feat(housekeeping): persist operator preferences

This commit is contained in:
Simo committed 2026-08-26 22:17:52 +02:00
1 parent 86a2d9d069
commit 2eb0456999
8 files changed
+566

No files matched your search

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: vi.fn(),
}));
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import {
type HousekeepingPreferencesActionDependencies,
loadHousekeepingPreferences,
saveHousekeepingPreferences,
} from "./housekeeping-preferences";
const registry: HousekeepingRegistry = { domains: [] };
const allowedContext = {
actor: { id: 42, username: "operator", rank: 0 },
isSuperAdmin: false,
has: (slug: string) => slug === "admin.dashboard",
hasAny: (...slugs: string[]) => slugs.includes("admin.dashboard"),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => slug === "admin.dashboard"),
};
function dependencies(
context = allowedContext,
): HousekeepingPreferencesActionDependencies & {
repository: HousekeepingPreferencesRepository;
} {
return {
repository: {
read: vi.fn().mockResolvedValue(defaultHousekeepingPreferences()),
upsert: vi.fn(),
},
registry,
getContext: vi.fn().mockResolvedValue(context),
};
}
describe("housekeeping preference actions", () => {
it("derives the read owner from request capability context", async () => {
const deps = dependencies();
const result = await loadHousekeepingPreferences(deps);
expect(result.ok).toBe(true);
expect(deps.repository.read).toHaveBeenCalledWith(42);
expect(deps.getContext).toHaveBeenCalledOnce();
});
it("rejects a denied operator without reading or writing another user preferences", async () => {
const deps = dependencies({ ...allowedContext, hasAny: () => false });
const result = await saveHousekeepingPreferences(
defaultHousekeepingPreferences(),
deps,
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(deps.repository.read).not.toHaveBeenCalled();
expect(deps.repository.upsert).not.toHaveBeenCalled();
});
it("validates writes and persists them for the context actor only", async () => {
const deps = dependencies();
const value = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
};
const result = await saveHousekeepingPreferences(value, deps);
expect(result).toMatchObject({ ok: true, data: value });
expect(deps.repository.upsert).toHaveBeenCalledWith(42, value);
});
it("returns a validation result before an invalid payload reaches persistence", async () => {
const deps = dependencies();
const result = await saveHousekeepingPreferences(
{ schemaVersion: 2 },
deps,
);
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(deps.repository.upsert).not.toHaveBeenCalled();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingResult,
ok,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
import {
type HousekeepingPreferences,
housekeepingPreferencesSchema,
} from "@/features/housekeeping/foundation/preferences/schema";
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { PERMS } from "@/lib/permission-slugs";
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
export interface HousekeepingPreferencesActionDependencies {
repository: HousekeepingPreferencesRepository;
registry: HousekeepingRegistry;
getContext?: () => Promise<HousekeepingCapabilityContext>;
}
export async function loadHousekeepingPreferences(
dependencies: HousekeepingPreferencesActionDependencies,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const context = await resolveContext(dependencies);
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
try {
const stored = await dependencies.repository.read(context.actor.id);
return ok(
reconcilePreferences(stored, dependencies.registry, context),
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.read",
correlationId,
);
}
}
export async function saveHousekeepingPreferences(
input: unknown,
dependencies: HousekeepingPreferencesActionDependencies,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const context = await resolveContext(dependencies);
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
const parsed = housekeepingPreferencesSchema.safeParse(input);
if (!parsed.success) {
return fail(
"VALIDATION",
"errors.housekeeping.preferences.invalid",
correlationId,
);
}
try {
await dependencies.repository.upsert(context.actor.id, parsed.data);
return ok(parsed.data, correlationId);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.save",
correlationId,
);
}
}
async function resolveContext(
dependencies: HousekeepingPreferencesActionDependencies,
): Promise<HousekeepingCapabilityContext> {
return dependencies.getContext
? dependencies.getContext()
: getHousekeepingCapabilityContext();
}