feat(19-02): add catalog-linked candidates, branched resolution, remove link/unlink routes
- getThreadWithCandidates LEFT JOINs globalItems with COALESCE for name, weight, price, image - createCandidate accepts and stores globalItemId - resolveThread branches: reference item (globalItemId set) vs standalone (full data copy) - Removed link/unlink endpoints from items route (replaced by direct globalItemId FK) - 6 new tests for catalog-linked candidates and branched resolution
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { globalItems, items } from "../../src/db/schema.ts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createCandidate,
|
||||
createThread,
|
||||
@@ -616,6 +618,161 @@ describe("Thread Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog-linked candidates (globalItemId)", () => {
|
||||
async function insertGlobalItem(
|
||||
testDb: any,
|
||||
data: {
|
||||
brand: string;
|
||||
model: string;
|
||||
weightGrams?: number;
|
||||
priceCents?: number;
|
||||
imageUrl?: string;
|
||||
},
|
||||
) {
|
||||
const [row] = await testDb
|
||||
.insert(globalItems)
|
||||
.values(data)
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
it("createCandidate with globalItemId stores the value on the candidate row", async () => {
|
||||
const gi = await insertGlobalItem(db, {
|
||||
brand: "Big Agnes",
|
||||
model: "Copper Spur HV UL2",
|
||||
weightGrams: 1270,
|
||||
priceCents: 44995,
|
||||
});
|
||||
const thread = await createThread(db, userId, {
|
||||
name: "Tent Research",
|
||||
categoryId: 1,
|
||||
});
|
||||
const candidate = await createCandidate(db, userId, thread.id, {
|
||||
name: "placeholder",
|
||||
categoryId: 1,
|
||||
globalItemId: gi.id,
|
||||
});
|
||||
|
||||
expect(candidate).toBeDefined();
|
||||
expect(candidate.globalItemId).toBe(gi.id);
|
||||
});
|
||||
|
||||
it("getThreadWithCandidates returns globalItemId field on each candidate", async () => {
|
||||
const gi = await insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "Hubba Hubba",
|
||||
weightGrams: 1540,
|
||||
});
|
||||
const thread = await createThread(db, userId, {
|
||||
name: "Tent Options",
|
||||
categoryId: 1,
|
||||
});
|
||||
await createCandidate(db, userId, thread.id, {
|
||||
name: "placeholder",
|
||||
categoryId: 1,
|
||||
globalItemId: gi.id,
|
||||
});
|
||||
|
||||
const result = await getThreadWithCandidates(db, userId, thread.id);
|
||||
expect(result?.candidates).toHaveLength(1);
|
||||
expect(result?.candidates[0].globalItemId).toBe(gi.id);
|
||||
});
|
||||
|
||||
it("getThreadWithCandidates merges global item data for candidates with globalItemId", async () => {
|
||||
const gi = await insertGlobalItem(db, {
|
||||
brand: "Nemo",
|
||||
model: "Tensor",
|
||||
weightGrams: 425,
|
||||
priceCents: 17995,
|
||||
});
|
||||
const thread = await createThread(db, userId, {
|
||||
name: "Pad Research",
|
||||
categoryId: 1,
|
||||
});
|
||||
await createCandidate(db, userId, thread.id, {
|
||||
name: "placeholder",
|
||||
categoryId: 1,
|
||||
globalItemId: gi.id,
|
||||
});
|
||||
|
||||
const result = await getThreadWithCandidates(db, userId, thread.id);
|
||||
expect(result?.candidates[0].name).toBe("Nemo Tensor");
|
||||
expect(result?.candidates[0].weightGrams).toBe(425);
|
||||
expect(result?.candidates[0].priceCents).toBe(17995);
|
||||
});
|
||||
|
||||
it("resolveThread with candidate having globalItemId creates a reference item", async () => {
|
||||
const gi = await insertGlobalItem(db, {
|
||||
brand: "Thermarest",
|
||||
model: "NeoAir XLite",
|
||||
weightGrams: 340,
|
||||
priceCents: 20995,
|
||||
});
|
||||
const thread = await createThread(db, userId, {
|
||||
name: "Pad Decision",
|
||||
categoryId: 1,
|
||||
});
|
||||
const candidate = await createCandidate(db, userId, thread.id, {
|
||||
name: "placeholder",
|
||||
categoryId: 1,
|
||||
globalItemId: gi.id,
|
||||
notes: "Great pad",
|
||||
});
|
||||
|
||||
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.item).toBeDefined();
|
||||
expect(result.item?.globalItemId).toBe(gi.id);
|
||||
expect(result.item?.name).toBe("Thermarest NeoAir XLite");
|
||||
// Reference item should NOT copy weight/price from candidate
|
||||
expect(result.item?.weightGrams).toBeNull();
|
||||
expect(result.item?.priceCents).toBeNull();
|
||||
expect(result.item?.notes).toBe("Great pad");
|
||||
});
|
||||
|
||||
it("resolveThread with standalone candidate creates a full data copy item", async () => {
|
||||
const thread = await createThread(db, userId, {
|
||||
name: "Stove Decision",
|
||||
categoryId: 1,
|
||||
});
|
||||
const candidate = await createCandidate(db, userId, thread.id, {
|
||||
name: "Jetboil Flash",
|
||||
categoryId: 1,
|
||||
weightGrams: 371,
|
||||
priceCents: 10995,
|
||||
notes: "Fast boil",
|
||||
productUrl: "https://example.com/jetboil",
|
||||
});
|
||||
|
||||
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.item?.globalItemId).toBeNull();
|
||||
expect(result.item?.name).toBe("Jetboil Flash");
|
||||
expect(result.item?.weightGrams).toBe(371);
|
||||
expect(result.item?.priceCents).toBe(10995);
|
||||
expect(result.item?.productUrl).toBe("https://example.com/jetboil");
|
||||
});
|
||||
|
||||
it("resolveThread reference item has brand+model as fallback name", async () => {
|
||||
const gi = await insertGlobalItem(db, {
|
||||
brand: "MSR",
|
||||
model: "PocketRocket 2",
|
||||
});
|
||||
const thread = await createThread(db, userId, {
|
||||
name: "Stove Research",
|
||||
categoryId: 1,
|
||||
});
|
||||
const candidate = await createCandidate(db, userId, thread.id, {
|
||||
name: "placeholder",
|
||||
categoryId: 1,
|
||||
globalItemId: gi.id,
|
||||
});
|
||||
|
||||
const result = await resolveThread(db, userId, thread.id, candidate.id);
|
||||
expect(result.item?.name).toBe("MSR PocketRocket 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-user isolation", () => {
|
||||
it("user cannot see other user's threads", async () => {
|
||||
const userId2 = await createSecondTestUser(db);
|
||||
|
||||
Reference in New Issue
Block a user