/** * Bug fix: AI-generated databases emit select/status/multi-select * `config.options` WITHOUT ids (bare `{ name, color }`) and store record * values as the option NAME (`{ optionId: "To Do" }`). Every id-based reader * (web chips, preselect, board grouping, filters) then failed to resolve the * value → empty chips, broken preselect, `optionId: undefined` commits. * * The fix canonicalizes at BOTH boundaries: * - READ: `toDto`/grouping resolves a stored NAME ref to the deterministic * `optionIdForName(name)` id (via `canonicalOptionValue`). * - WRITE: `writeProperties`/`bulkUpdate` store the id, not the name * (via `coerceAndCanonical`). * * These tests drive the REAL `DatabaseQueryService.list()` through a counting * prisma mock seeded with a LEGACY AI-shaped database (id-less STATUS options, * a record whose value is the option NAME) and assert the wire DTO is * canonicalized (id present, matches the deterministic id). */ import { describe, expect, it } from "vitest"; import { PrismaClient } from "@prisma/client"; import type { PrismaService } from "../../../common/prisma.service.js"; import { CacheService } from "../../../common/cache.js"; import { setPermissionCache } from "../../../lib/authorize.js"; import { ActorContext } from "../../../common/actor-context.js"; import type { AuthContext } from "../../../common/types.js"; import { optionIdForName } from "@factory/ai"; import { DatabaseQueryService } from "../query.service.js"; const WS = "ws_opt"; const USER = "user_opt"; const DB = "db_opt"; const REC = "rec_opt"; const STATUS_PROP = "prop_status"; const actor: AuthContext = { kind: "user", userId: USER }; /** A prisma mock for the LEGACY AI-shaped database: a STATUS property whose * `config.options` carry NO ids + a record whose value is the option NAME. */ function legacyPrisma() { const calls: string[] = []; const prisma = { read(): PrismaClient { return prisma as unknown as PrismaClient; }, write(): PrismaClient { return prisma as unknown as PrismaClient; }, database: { async findUnique(): Promise { calls.push("database.findUnique"); return { id: DB, workspaceId: WS, isArchived: false, pageId: "pg_opt", title: "Legacy AI DB" }; } }, databaseProperty: { async findMany(): Promise { calls.push("databaseProperty.findMany"); return [ { id: "prop_title", databaseId: DB, name: "Name", type: "TITLE", position: "a0", isTitle: true, config: {} }, // LEGACY: options have NO id (the AI path). { id: STATUS_PROP, databaseId: DB, name: "Status", type: "STATUS", position: "a1", isTitle: false, config: { options: [{ name: "To Do", color: "default" }, { name: "Done", color: "green" }] } } ]; } }, databaseRecord: { async findMany(): Promise { calls.push("databaseRecord.findMany"); return [ { id: REC, workspaceId: WS, databaseId: DB, pageId: "pg_rec", icon: null, coverUrl: null, archivedAt: null, version: 1, position: "a5", createdTime: new Date(), lastEditedTime: new Date(), createdBy: USER, lastEditedBy: USER, createdAt: new Date(), page: { title: "First task", icon: null }, // LEGACY: the value is the option NAME, not an id. properties: [{ propertyId: STATUS_PROP, value: { optionId: "To Do" } }] } ]; } } }; return { prisma, calls }; } function makeService(prisma: unknown) { return new DatabaseQueryService( prisma as unknown as PrismaService, { write: async (): Promise => {}, audit: async (): Promise => {} } as never, { publish: async (): Promise => {} } as never, new CacheService(undefined, undefined), {} as never ); } describe("option-id canonicalization (legacy AI DBs)", () => { it("READ: resolves a name-stored option ref to the deterministic id in the DTO", async () => { setPermissionCache(async () => ({ level: "EDIT", cached: true })); const { prisma } = legacyPrisma(); const service = makeService(prisma); const res = await ActorContext.run(actor, () => service.list(DB, USER, { limit: "50" }) ); const records = res.data as Array<{ properties: Record }>; const status = records[0]?.properties[STATUS_PROP]; // The legacy NAME ("To Do") is resolved to the deterministic id. expect(status?.optionId).toBe(optionIdForName("To Do")); expect(status?.optionId).not.toBe("To Do"); }); it("READ: leaves an already-canonical id ref unchanged", async () => { setPermissionCache(async () => ({ level: "EDIT", cached: true })); const id = optionIdForName("To Do"); const { prisma } = legacyPrisma(); // Patch the mock row to a modern id ref. const orig = prisma.databaseRecord.findMany.bind(prisma.databaseRecord); prisma.databaseRecord.findMany = (async () => [ { id: REC, workspaceId: WS, databaseId: DB, pageId: "pg_rec", icon: null, coverUrl: null, archivedAt: null, version: 1, position: "a5", createdTime: new Date(), lastEditedTime: new Date(), createdBy: USER, lastEditedBy: USER, createdAt: new Date(), page: { title: "First task", icon: null }, properties: [{ propertyId: STATUS_PROP, value: { optionId: id } }] } ]) as unknown as typeof orig; const service = makeService(prisma); const res = await ActorContext.run(actor, () => service.list(DB, USER, { limit: "50" })); const records = res.data as Array<{ properties: Record }>; expect(records[0]?.properties[STATUS_PROP]?.optionId).toBe(id); }); it("READ: a name ref with NO matching option is preserved verbatim (no data loss)", async () => { setPermissionCache(async () => ({ level: "EDIT", cached: true })); const { prisma } = legacyPrisma(); // A value that is neither a known id nor a known option name (e.g. an // option that was later renamed/removed) must pass through unchanged. prisma.databaseRecord.findMany = (async () => [ { id: REC, workspaceId: WS, databaseId: DB, pageId: "pg_rec", icon: null, coverUrl: null, archivedAt: null, version: 1, position: "a5", createdTime: new Date(), lastEditedTime: new Date(), createdBy: USER, lastEditedBy: USER, createdAt: new Date(), page: { title: "First task", icon: null }, properties: [{ propertyId: STATUS_PROP, value: { optionId: "Renamed Later" } }] } ]) as unknown as typeof prisma.databaseRecord.findMany; const service = makeService(prisma); const res = await ActorContext.run(actor, () => service.list(DB, USER, { limit: "50" })); const records = res.data as Array<{ properties: Record }>; expect(records[0]?.properties[STATUS_PROP]?.optionId).toBe("Renamed Later"); }); });