infoalloggi-monorepo/apps/infoalloggi/src/lib/dateParserJsonKysely.test.ts

390 lines
12 KiB
TypeScript
Raw Normal View History

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 }[] }[]>();
});
});