feat: update Servizio component to handle interruzioneDays and acceptedInterr logic refactor: simplify servizio_actions by removing unused InterruzioneServizio and EditParametri components feat: enhance FormEditServizioAdmin to include interruzioneDays and acceptedInterr fields fix: adjust email service to delete obsolete emails and handle invalid email types gracefully chore: update stripe controller to utilize interruzioneDays for lock expiration logic fix: ensure correct handling of servizio expiration and interruzione logic in service controller
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import type { KyselyPlugin, UnknownRow } from "kysely";
|
||
import {
|
||
createQueryId,
|
||
DummyDriver,
|
||
Kysely,
|
||
NoResultError,
|
||
PostgresAdapter,
|
||
PostgresIntrospector,
|
||
PostgresQueryCompiler,
|
||
} from "kysely";
|
||
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||
import {
|
||
DateJsonParserPlugin,
|
||
type WithParsedDates,
|
||
withParsedDates,
|
||
} from "~/lib/dateParserJsonKysely";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Schema
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface Database {
|
||
box: BoxTable;
|
||
item: ItemTable;
|
||
}
|
||
|
||
interface BoxTable {
|
||
id: number;
|
||
}
|
||
|
||
interface ItemTable {
|
||
id: number;
|
||
box_id: number;
|
||
created_at: Date;
|
||
updated_at: Date | null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DummyDriver db — used only for compile-time type tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const db = new Kysely<Database>({
|
||
dialect: {
|
||
createAdapter: () => new PostgresAdapter(),
|
||
createDriver: () => new DummyDriver(),
|
||
// biome-ignore lint/suspicious/noExplicitAny: required by Kysely dialect contract
|
||
createIntrospector: (db: Kysely<any>) => new PostgresIntrospector(db),
|
||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||
},
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fake query helper — pipes synthetic rows through the plugin at runtime
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class TestNoResultError extends Error {
|
||
constructor() {
|
||
super("No result (test)");
|
||
this.name = "TestNoResultError";
|
||
}
|
||
}
|
||
|
||
function makeFakeQuery<T>(rows: T[]) {
|
||
return {
|
||
withPlugin(plugin: KyselyPlugin) {
|
||
async function run(): Promise<T[]> {
|
||
const result = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: { rows: rows as UnknownRow[] },
|
||
});
|
||
return result.rows as T[];
|
||
}
|
||
return {
|
||
execute: run,
|
||
async executeTakeFirst() {
|
||
return (await run())[0];
|
||
},
|
||
async executeTakeFirstOrThrow(
|
||
errorConstructor?: (new () => Error) | ((node: unknown) => Error),
|
||
) {
|
||
const row = (await run())[0];
|
||
if (row !== undefined) return row;
|
||
if (typeof errorConstructor === "function") {
|
||
try {
|
||
// try constructor form first
|
||
throw new (errorConstructor as new () => Error)();
|
||
} catch (e) {
|
||
if (e instanceof Error) throw e;
|
||
// it was a factory function, not a constructor
|
||
throw (errorConstructor as (node: unknown) => Error)({});
|
||
}
|
||
}
|
||
throw new NoResultError({} as never);
|
||
},
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DateJsonParserPlugin — direct unit tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("DateJsonParserPlugin", () => {
|
||
it("parses a top-level date string", async () => {
|
||
const plugin = new DateJsonParserPlugin(["created_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: { rows: [{ created_at: "2024-01-15T10:00:00.000Z" }] },
|
||
});
|
||
expect(rows[0]?.created_at).toBeInstanceOf(Date);
|
||
expect((rows[0]?.created_at as Date).toISOString()).toBe(
|
||
"2024-01-15T10:00:00.000Z",
|
||
);
|
||
});
|
||
|
||
it("parses dates nested inside a JSON array", async () => {
|
||
const plugin = new DateJsonParserPlugin(["items.created_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: {
|
||
rows: [
|
||
{
|
||
items: [
|
||
{ id: 1, created_at: "2024-01-01T00:00:00.000Z" },
|
||
{ id: 2, created_at: "2024-06-15T12:30:00.000Z" },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
const items = rows[0]?.items as { id: number; created_at: Date }[];
|
||
expect(items[0]?.created_at).toBeInstanceOf(Date);
|
||
expect(items[0]?.created_at.toISOString()).toBe("2024-01-01T00:00:00.000Z");
|
||
expect(items[1]?.created_at).toBeInstanceOf(Date);
|
||
expect(items[1]?.created_at.toISOString()).toBe("2024-06-15T12:30:00.000Z");
|
||
});
|
||
|
||
it("parses a date nested inside a JSON object", async () => {
|
||
const plugin = new DateJsonParserPlugin(["meta.published_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: {
|
||
rows: [{ meta: { published_at: "2024-03-01T00:00:00.000Z" } }],
|
||
},
|
||
});
|
||
const meta = rows[0]?.meta as { published_at: Date };
|
||
expect(meta.published_at).toBeInstanceOf(Date);
|
||
});
|
||
|
||
it("leaves null values untouched", async () => {
|
||
const plugin = new DateJsonParserPlugin(["items.created_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: { rows: [{ items: [{ id: 1, created_at: null }] }] },
|
||
});
|
||
const items = rows[0]?.items as { created_at: null }[];
|
||
expect(items[0]?.created_at).toBeNull();
|
||
});
|
||
|
||
it("leaves undefined values untouched", async () => {
|
||
const plugin = new DateJsonParserPlugin(["created_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: { rows: [{ created_at: undefined }] },
|
||
});
|
||
expect(rows[0]?.created_at).toBeUndefined();
|
||
});
|
||
|
||
it("leaves non-string primitive values untouched", async () => {
|
||
const plugin = new DateJsonParserPlugin(["count"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: { rows: [{ count: 42 }] },
|
||
});
|
||
expect(rows[0]?.count).toBe(42);
|
||
});
|
||
|
||
it("applies multiple paths independently", async () => {
|
||
const plugin = new DateJsonParserPlugin(["created_at", "updated_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: {
|
||
rows: [
|
||
{
|
||
created_at: "2024-01-01T00:00:00.000Z",
|
||
updated_at: "2024-06-01T00:00:00.000Z",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expect(rows[0]?.created_at).toBeInstanceOf(Date);
|
||
expect(rows[0]?.updated_at).toBeInstanceOf(Date);
|
||
});
|
||
|
||
it("parses the same path across multiple result rows", async () => {
|
||
const plugin = new DateJsonParserPlugin(["created_at"]);
|
||
const { rows } = await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: {
|
||
rows: [
|
||
{ created_at: "2024-01-01T00:00:00.000Z" },
|
||
{ created_at: "2024-02-01T00:00:00.000Z" },
|
||
{ created_at: "2024-03-01T00:00:00.000Z" },
|
||
],
|
||
},
|
||
});
|
||
for (const row of rows) {
|
||
expect(row.created_at).toBeInstanceOf(Date);
|
||
}
|
||
});
|
||
|
||
it("does not mutate the original rows array", async () => {
|
||
const original = [{ created_at: "2024-01-01T00:00:00.000Z" }];
|
||
const plugin = new DateJsonParserPlugin(["created_at"]);
|
||
await plugin.transformResult({
|
||
queryId: createQueryId(),
|
||
result: { rows: original },
|
||
});
|
||
// original row object should be untouched (plugin shallow-copies each row)
|
||
expect(original[0]?.created_at).toBe("2024-01-01T00:00:00.000Z");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// withParsedDates — runtime behaviour
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("withParsedDates – runtime", () => {
|
||
it("execute() returns all rows with dates parsed", async () => {
|
||
const rows = await withParsedDates(
|
||
makeFakeQuery([
|
||
{ id: 1, items: [{ created_at: "2024-01-01T00:00:00.000Z" }] },
|
||
{ id: 2, items: [{ created_at: "2024-06-01T00:00:00.000Z" }] },
|
||
]),
|
||
["items.created_at"],
|
||
).execute();
|
||
|
||
expect(rows).toHaveLength(2);
|
||
expect(rows[0]?.items[0]?.created_at).toBeInstanceOf(Date);
|
||
expect(rows[1]?.items[0]?.created_at).toBeInstanceOf(Date);
|
||
});
|
||
|
||
it("executeTakeFirst() returns the first row with date parsed", async () => {
|
||
const row = await withParsedDates(
|
||
makeFakeQuery([
|
||
{ items: [{ created_at: "2024-01-01T00:00:00.000Z" }] },
|
||
{ items: [{ created_at: "2099-01-01T00:00:00.000Z" }] },
|
||
]),
|
||
["items.created_at"],
|
||
).executeTakeFirst();
|
||
|
||
expect(row).toBeDefined();
|
||
expect(row?.items[0]?.created_at).toBeInstanceOf(Date);
|
||
expect(row?.items[0]?.created_at.toISOString()).toBe(
|
||
"2024-01-01T00:00:00.000Z",
|
||
);
|
||
});
|
||
|
||
it("executeTakeFirst() returns undefined when there are no rows", async () => {
|
||
const row = await withParsedDates(
|
||
makeFakeQuery<{ items: { created_at: string }[] }>([]),
|
||
["items.created_at"],
|
||
).executeTakeFirst();
|
||
|
||
expect(row).toBeUndefined();
|
||
});
|
||
|
||
it("executeTakeFirstOrThrow() returns the first row with date parsed", async () => {
|
||
const row = await withParsedDates(
|
||
makeFakeQuery([{ items: [{ created_at: "2024-03-23T00:00:00.000Z" }] }]),
|
||
["items.created_at"],
|
||
).executeTakeFirstOrThrow();
|
||
|
||
expect(row.items[0]?.created_at).toBeInstanceOf(Date);
|
||
expect(row.items[0]?.created_at.toISOString()).toBe(
|
||
"2024-03-23T00:00:00.000Z",
|
||
);
|
||
});
|
||
|
||
it("executeTakeFirstOrThrow() throws NoResultError by default when empty", async () => {
|
||
await expect(
|
||
withParsedDates(makeFakeQuery<{ items: { created_at: string }[] }>([]), [
|
||
"items.created_at",
|
||
]).executeTakeFirstOrThrow(),
|
||
).rejects.toBeInstanceOf(NoResultError);
|
||
});
|
||
|
||
it("executeTakeFirstOrThrow() throws a custom error class when provided", async () => {
|
||
await expect(
|
||
withParsedDates(makeFakeQuery<{ items: { created_at: string }[] }>([]), [
|
||
"items.created_at",
|
||
]).executeTakeFirstOrThrow(TestNoResultError),
|
||
).rejects.toBeInstanceOf(TestNoResultError);
|
||
});
|
||
|
||
it("executeTakeFirstOrThrow() custom error has the expected message", async () => {
|
||
await expect(
|
||
withParsedDates(makeFakeQuery<{ items: { created_at: string }[] }>([]), [
|
||
"items.created_at",
|
||
]).executeTakeFirstOrThrow(TestNoResultError),
|
||
).rejects.toThrow("No result (test)");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// withParsedDates — type-level tests (compile-time only, no runtime assertions)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("withParsedDates – types", () => {
|
||
it("transforms string → Date for a nested array field", () => {
|
||
const fakeRows: { items: { created_at: string }[] }[] = [];
|
||
const query = withParsedDates(makeFakeQuery(fakeRows), [
|
||
"items.created_at",
|
||
]);
|
||
type Result = Awaited<ReturnType<typeof query.execute>>;
|
||
expectTypeOf<Result>().toEqualTypeOf<{ items: { created_at: Date }[] }[]>();
|
||
});
|
||
|
||
it("preserves unrelated fields unchanged", () => {
|
||
const fakeRows: {
|
||
id: number;
|
||
items: { created_at: string; name: string }[];
|
||
}[] = [];
|
||
const query = withParsedDates(makeFakeQuery(fakeRows), [
|
||
"items.created_at",
|
||
]);
|
||
type Result = Awaited<ReturnType<typeof query.execute>>;
|
||
expectTypeOf<Result>().toEqualTypeOf<
|
||
{ id: number; items: { created_at: Date; name: string }[] }[]
|
||
>();
|
||
});
|
||
|
||
it("executeTakeFirst() returns Row | undefined", () => {
|
||
const fakeRows: { items: { created_at: string }[] }[] = [];
|
||
const query = withParsedDates(makeFakeQuery(fakeRows), [
|
||
"items.created_at",
|
||
]);
|
||
type Result = Awaited<ReturnType<typeof query.executeTakeFirst>>;
|
||
expectTypeOf<Result>().toEqualTypeOf<
|
||
{ items: { created_at: Date }[] } | undefined
|
||
>();
|
||
});
|
||
|
||
it("executeTakeFirstOrThrow() returns Row (never undefined)", () => {
|
||
const fakeRows: { items: { created_at: string }[] }[] = [];
|
||
const query = withParsedDates(makeFakeQuery(fakeRows), [
|
||
"items.created_at",
|
||
]);
|
||
type Result = Awaited<ReturnType<typeof query.executeTakeFirstOrThrow>>;
|
||
expectTypeOf<Result>().toEqualTypeOf<{ items: { created_at: Date }[] }>();
|
||
});
|
||
|
||
it("WithParsedDates utility type transforms correctly", () => {
|
||
type Input = { items: { created_at: string; id: number }[] };
|
||
type Output = WithParsedDates<Input, ["items.created_at"]>;
|
||
expectTypeOf<Output>().toEqualTypeOf<{
|
||
items: { created_at: Date; id: number }[];
|
||
}>();
|
||
});
|
||
|
||
it("WithParsedDates preserves null unions on the array field", () => {
|
||
type Input = { items: { created_at: string }[] | null };
|
||
type Output = WithParsedDates<Input, ["items.created_at"]>;
|
||
expectTypeOf<Output>().toEqualTypeOf<{
|
||
items: { created_at: Date }[] | null;
|
||
}>();
|
||
});
|
||
|
||
it("type-checks against real DummyDriver Kysely instance", () => {
|
||
const query = withParsedDates(
|
||
db
|
||
.selectFrom("box")
|
||
.select((eb) => [
|
||
jsonArrayFrom(
|
||
eb
|
||
.selectFrom("item")
|
||
.selectAll()
|
||
.whereRef("item.box_id", "=", "box.id"),
|
||
).as("items"),
|
||
]),
|
||
["items.created_at"],
|
||
);
|
||
// created_at inside items must be Date, not string — compile-time check only
|
||
type Result = Awaited<ReturnType<typeof query.execute>>;
|
||
expectTypeOf<Result>().toExtend<{ items: { created_at: Date }[] }[]>();
|
||
});
|
||
});
|