166 lines
4.0 KiB
TypeScript
166 lines
4.0 KiB
TypeScript
import type { SQL } from "drizzle-orm";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
import { HotelMutationFailure } from "./mutations";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
rows: [] as Array<Record<string, unknown>>,
|
|
writes: [] as Array<Record<string, unknown>>,
|
|
}));
|
|
|
|
vi.mock("@/lib/db", async () => {
|
|
const schema = await import("@/db/schema");
|
|
const database = {
|
|
select: vi.fn(() => {
|
|
const query = {
|
|
from: vi.fn(() => query),
|
|
where: vi.fn((_condition: SQL | undefined) => {
|
|
const result = Promise.resolve(state.rows) as Promise<
|
|
Array<Record<string, unknown>>
|
|
> & {
|
|
limit: (count: number) => Promise<Array<Record<string, unknown>>>;
|
|
};
|
|
result.limit = vi.fn(() => result);
|
|
return result;
|
|
}),
|
|
};
|
|
return query;
|
|
}),
|
|
insert: vi.fn(() => ({
|
|
values: (values: Record<string, unknown>) => ({
|
|
onDuplicateKeyUpdate: async () => {
|
|
state.writes.push(values);
|
|
},
|
|
}),
|
|
})),
|
|
};
|
|
return { ...schema, db: database };
|
|
});
|
|
|
|
import { executeHotelMutationOperation } from "./mutations-runtime";
|
|
|
|
const capability: HousekeepingCapabilityContext = {
|
|
actor: { id: 42, username: "operator", rank: 7 },
|
|
isSuperAdmin: false,
|
|
has: () => true,
|
|
hasAny: () => true,
|
|
hasAll: () => true,
|
|
};
|
|
const context = {
|
|
capability,
|
|
correlationId: "radio-runtime",
|
|
legacy: true,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
state.rows = [];
|
|
state.writes = [];
|
|
});
|
|
|
|
describe("Hotel radio settings runtime", () => {
|
|
it("rejects an unrelated setting key before any write", async () => {
|
|
await expect(
|
|
executeHotelMutationOperation(
|
|
"radio.settings.save-one",
|
|
{ key: "cms_secret", value: "private" },
|
|
context,
|
|
),
|
|
).rejects.toBeInstanceOf(HotelMutationFailure);
|
|
expect(state.writes).toEqual([]);
|
|
});
|
|
|
|
it("rejects duplicate and invalid bulk entries atomically", async () => {
|
|
for (const entries of [
|
|
[
|
|
{ key: "radio_name", value: "one" },
|
|
{ key: "radio_name", value: "two" },
|
|
],
|
|
[
|
|
{ key: "radio_name", value: "one" },
|
|
{ key: "site_name", value: "forged" },
|
|
],
|
|
]) {
|
|
await expect(
|
|
executeHotelMutationOperation(
|
|
"radio.settings.save-many",
|
|
{ entries },
|
|
context,
|
|
),
|
|
).rejects.toBeInstanceOf(HotelMutationFailure);
|
|
expect(state.writes).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("accepts the complete 102-entry curated form within the 500-entry bound", async () => {
|
|
const entries = Array.from({ length: 102 }, (_, index) => ({
|
|
key: `radio_curated_${index}`,
|
|
value: `value-${index}`,
|
|
}));
|
|
|
|
await executeHotelMutationOperation(
|
|
"radio.settings.save-many",
|
|
{ entries },
|
|
context,
|
|
);
|
|
|
|
expect(state.writes).toHaveLength(102);
|
|
});
|
|
|
|
it("rejects a bulk request above 500 entries", async () => {
|
|
const entries = Array.from({ length: 501 }, (_, index) => ({
|
|
key: `radio_setting_${index}`,
|
|
value: "value",
|
|
}));
|
|
|
|
await expect(
|
|
executeHotelMutationOperation(
|
|
"radio.settings.save-many",
|
|
{ entries },
|
|
context,
|
|
),
|
|
).rejects.toBeInstanceOf(HotelMutationFailure);
|
|
expect(state.writes).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"radio.settings.save-one" as const,
|
|
{ key: "radio_stream_url", value: "https://private.example/stream" },
|
|
],
|
|
[
|
|
"radio.settings.save-many" as const,
|
|
{
|
|
entries: [{ key: "radio_name", value: "private station name" }],
|
|
},
|
|
],
|
|
[
|
|
"radio.points.save" as const,
|
|
{
|
|
radio_points_enabled: "1",
|
|
radio_points_per_minute: "17",
|
|
radio_points_currency: "credits",
|
|
radio_points_max_per_day: "34",
|
|
radio_points_min_listeners: "2",
|
|
},
|
|
],
|
|
])(
|
|
"does not expose private values in %s audit snapshots",
|
|
async (operation, input) => {
|
|
const snapshot = await executeHotelMutationOperation(
|
|
operation,
|
|
input,
|
|
context,
|
|
);
|
|
const serialized = JSON.stringify(snapshot);
|
|
for (const privateValue of [
|
|
"https://private.example/stream",
|
|
"private station name",
|
|
"17",
|
|
"34",
|
|
]) {
|
|
expect(serialized).not.toContain(privateValue);
|
|
}
|
|
},
|
|
);
|
|
});
|