import knex, { Knex } from "knex"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { HashedCacheManager } from "./hashed-cache-manager.js"; import { ERPObjType } from "../../types/erp-types.js"; // A minimal IToRESTApiObject stand-in: data lives as own enumerable props (so // hashRecord's stringify captures the content) plus a toRESTApiObject() that // extractPrimaryKey reads for the entity identity. function woOp(fields: Record) { return { ...fields, toRESTApiObject: () => ({ ...fields }), } as unknown as object; } const KEY = { workOrderId: "J1", lot: "", split: "", sub: "", sequenceNumber: "10", }; async function createSchema(db: Knex): Promise { await db.schema.createTable("sdk_cache", (table) => { table.increments("id"); table.string("type").notNullable().index(); table.string("entity_key").notNullable(); table.string("content_hash").notNullable(); table.bigInteger("updated_at").notNullable(); table.unique(["type", "entity_key"]); }); } describe("HashedCacheManager per-entity dedupe", () => { let db: Knex; let cache: HashedCacheManager; beforeEach(async () => { db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true, }); await createSchema(db); // Pass ttl explicitly so the constructor never reaches CoreConfiguration. cache = new HashedCacheManager({ ttl: 3600, db }); }); afterEach(async () => { await cache.destroy(); await db.destroy(); }); it("re-sends a record whose content returned to a previously-seen state (A -> B -> A)", async () => { const released = woOp({ ...KEY, status: "RELEASED", closedDate: null }); const completed = woOp({ ...KEY, status: "COMPLETED", closedDate: "2026-01-01T00:00:00.000Z", }); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, released); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, completed); // The old set-membership cache returned false here (the RELEASED content was // seen before); per-entity dedupe compares against the LAST content (completed). expect( await cache.hasChanged(ERPObjType.WORK_ORDER_OPERATIONS, released) ).toBe(true); }); it("dedupes an unchanged record", async () => { const released = woOp({ ...KEY, status: "RELEASED", closedDate: null }); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, released); expect( await cache.hasChanged(ERPObjType.WORK_ORDER_OPERATIONS, released) ).toBe(false); }); it("keeps one row per entity and overwrites its content hash (upsert, not insert-only)", async () => { const released = woOp({ ...KEY, status: "RELEASED", closedDate: null }); const completed = woOp({ ...KEY, status: "COMPLETED", closedDate: "2026-01-01T00:00:00.000Z", }); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, released); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, completed); const rows = await db("sdk_cache").select("content_hash"); expect(rows).toHaveLength(1); expect(rows[0].content_hash).toBe(HashedCacheManager.hashRecord(completed)); }); it("isolates entities: a change to one does not dedupe another", async () => { const op10 = woOp({ ...KEY, sequenceNumber: "10", status: "RELEASED" }); const op20 = woOp({ ...KEY, sequenceNumber: "20", status: "RELEASED" }); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, op10); expect(await cache.hasChanged(ERPObjType.WORK_ORDER_OPERATIONS, op10)).toBe( false ); expect(await cache.hasChanged(ERPObjType.WORK_ORDER_OPERATIONS, op20)).toBe( true ); }); it("never dedupes or caches a record whose identity cannot be computed", async () => { // WORK_ORDERS requires workOrderId; omit it so extractPrimaryKey throws. const keyless = woOp({ lot: "", split: "", sub: "", status: "RELEASED" }); expect(await cache.hasChanged(ERPObjType.WORK_ORDERS, keyless)).toBe(true); expect(await cache.store(ERPObjType.WORK_ORDERS, keyless)).toBe(false); expect(await db("sdk_cache").count("* as c").first()).toMatchObject({ c: 0 }); // Still reported as changed on the next pass (never silently dropped). expect(await cache.hasChanged(ERPObjType.WORK_ORDERS, keyless)).toBe(true); }); it("removeExpiredObjects prunes rows older than the TTL by updated_at", async () => { const released = woOp({ ...KEY, status: "RELEASED", closedDate: null }); await cache.store(ERPObjType.WORK_ORDER_OPERATIONS, released); // Age the row well past the 3600s TTL. await db("sdk_cache").update({ updated_at: Date.now() - 4000 * 1000 }); await cache.removeExpiredObjects(); expect(await db("sdk_cache").count("* as c").first()).toMatchObject({ c: 0 }); }); });