Files
EpicNext-Cms/src/features/housekeeping/foundation/inbox/inbox-service.test.ts
T

222 lines
6.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingInboxSource,
type HousekeepingWorkItem,
ok,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
import { createHousekeepingInboxService, INBOX_POLICY } from "./inbox-service";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
function item(
itemId: string,
options: Partial<HousekeepingWorkItem> = {},
): HousekeepingWorkItem {
return {
sourceId: "people.queue",
itemId,
deduplicationKey: `people.queue:${itemId}`,
domain: "people",
capability: anyCapability(PERMS.USERS_VIEW),
severity: "info",
priority: "normal",
ageMs: 1_000,
state: "open",
occurredAt: "2026-08-30T10:00:00.000Z",
titleKey: "pages.housekeeping.items.test",
href: "/ase/people/users",
freshness: "fresh",
actions: [],
...options,
};
}
function source(
id: string,
permission: string,
getItems: HousekeepingInboxSource["getItems"],
owner: "people" | "content" = "people",
): HousekeepingInboxSource {
return {
id,
owner,
capability: anyCapability(permission),
getItems,
};
}
function manifest(
id: "people" | "content",
permission: string,
inboxSources: readonly HousekeepingInboxSource[],
): HousekeepingDomainManifest {
return {
id,
labelKey: `pages.housekeeping.domains.${id}.title`,
descriptionKey: `pages.housekeeping.domains.${id}.description`,
iconId: id === "people" ? "users" : "file-text",
canonicalHref: `/ase/${id}`,
capability: anyCapability(permission),
routes: [],
searchProviders: [],
inboxSources,
widgets: [],
};
}
function registry(
...domains: readonly HousekeepingDomainManifest[]
): HousekeepingRegistry {
return { domains };
}
describe("Housekeeping inbox service", () => {
it("locks timeout, cap, and deduplication policy", () => {
expect(INBOX_POLICY).toEqual({
timeoutMs: 2_000,
combinedLimit: 200,
dedupe: "sourceId:itemId",
});
});
it("filters domain and source capabilities before invoking sources", async () => {
const permitted = vi.fn(async () =>
ok({ availability: "available" as const, items: [] }, "permitted"),
);
const forbiddenSource = vi.fn(async () =>
ok({ availability: "available" as const, items: [] }, "source"),
);
const forbiddenDomain = vi.fn(async () =>
ok({ availability: "available" as const, items: [] }, "domain"),
);
const service = createHousekeepingInboxService({
registry: registry(
manifest("people", PERMS.USERS_VIEW, [
source("people.ready", PERMS.USERS_VIEW, permitted),
source("people.bans", PERMS.BANS_VIEW, forbiddenSource),
]),
manifest("content", PERMS.NEWS_VIEW, [
source("content.users", PERMS.USERS_VIEW, forbiddenDomain, "content"),
]),
),
createCorrelationId: () => "inbox-capabilities",
});
await service(context([PERMS.USERS_VIEW]));
expect(permitted).toHaveBeenCalledOnce();
expect(forbiddenSource).not.toHaveBeenCalled();
expect(forbiddenDomain).not.toHaveBeenCalled();
});
it("deduplicates by source/item and orders by priority then oldest age", async () => {
const items = [
item("normal", { priority: "normal", ageMs: 900 }),
item("high-new", { priority: "high", ageMs: 100 }),
item("critical", { priority: "critical", ageMs: 10 }),
item("high-old", { priority: "high", ageMs: 1_000 }),
item("low", { priority: "low", ageMs: 5_000 }),
item("high-old", { priority: "high", ageMs: 999 }),
];
const service = createHousekeepingInboxService({
registry: registry(
manifest("people", PERMS.USERS_VIEW, [
source("people.queue", PERMS.USERS_VIEW, async () =>
ok({ availability: "available", items }, "ordered"),
),
]),
),
createCorrelationId: () => "inbox-order",
});
const response = await service(context([PERMS.USERS_VIEW]));
expect(response.items.map((entry) => entry.itemId)).toEqual([
"critical",
"high-old",
"high-new",
"normal",
"low",
]);
});
it("filters item capabilities before enforcing the 200-item cap", async () => {
const items = Array.from({ length: 230 }, (_, index) =>
item(String(index), {
capability: anyCapability(
index < 20 ? PERMS.BANS_VIEW : PERMS.USERS_VIEW,
),
}),
);
const service = createHousekeepingInboxService({
registry: registry(
manifest("people", PERMS.USERS_VIEW, [
source("people.queue", PERMS.USERS_VIEW, async () =>
ok({ availability: "available", items }, "capped"),
),
]),
),
createCorrelationId: () => "inbox-cap",
});
const response = await service(context([PERMS.USERS_VIEW]));
expect(response.items).toHaveLength(200);
expect(response.items[0]?.itemId).toBe("20");
expect(response.items.at(-1)?.itemId).toBe("219");
});
it("returns successful items with stable timeout and source errors", async () => {
let timeoutSignal: AbortSignal | undefined;
const service = createHousekeepingInboxService({
registry: registry(
manifest("people", PERMS.USERS_VIEW, [
source("people.timeout", PERMS.USERS_VIEW, (_context, signal) => {
timeoutSignal = signal;
return new Promise(() => undefined);
}),
source("people.failed", PERMS.USERS_VIEW, async () =>
fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
"failed",
),
),
source("people.ready", PERMS.USERS_VIEW, async () =>
ok(
{
availability: "available",
items: [item("ready", { sourceId: "people.ready" })],
},
"ready",
),
),
]),
),
createCorrelationId: () => "inbox-partial",
timeoutMs: 5,
});
const response = await service(context([PERMS.USERS_VIEW]));
expect(response.items.map((entry) => entry.itemId)).toEqual(["ready"]);
expect(response.errors).toEqual([
{ sourceId: "people.timeout", code: "TIMEOUT" },
{ sourceId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" },
]);
expect(timeoutSignal?.aborted).toBe(true);
expect(response.correlationId).toBe("inbox-partial");
});
});