Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 165 additions & 94 deletions apps/api/__tests__/integration/trpc/catalog-fan-out.test.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,28 @@
/**
* Integration Tests: Catalog Router Fan-Out
* Integration Tests: Catalog Router Dirty Marking
*
* Verifies that catalog deletes enqueue background fan-out jobs with the
* affected published product IDs captured before destructive FK updates.
* Verifies that catalog mutations mark affected published passports dirty
* inline instead of queueing a background fan-out task.
*/

// Load setup first (loads .env.test and configures cleanup)
import "../../setup";

import { beforeEach, describe, expect, it, mock } from "bun:test";
import { beforeEach, describe, expect, it } from "bun:test";
import { createPassportForVariant } from "@v1/db/queries/products";
import * as schema from "@v1/db/schema";
import { createTestBrand, createTestUser, testDb } from "@v1/db/testing";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import type { AuthenticatedTRPCContext } from "../../../src/trpc/init";
import { catalogRouter } from "../../../src/trpc/routers/catalog";

type TriggerCall = {
id: string;
payload: {
brandId: string;
entityType: string;
entityId: string;
productIds?: string[];
};
options?: {
concurrencyKey?: string;
delay?: string;
};
type PassportFixture = {
productId: string;
variantId: string;
passportId: string;
};

const triggerCalls: TriggerCall[] = [];

const triggerMock = mock(
async (
id: string,
payload: TriggerCall["payload"],
options?: TriggerCall["options"],
) => {
triggerCalls.push({ id, payload, options });
return { id: `run_${triggerCalls.length}` } as const;
},
);

mock.module("@trigger.dev/sdk/v3", () => ({
tasks: {
trigger: triggerMock,
},
}));

import { catalogRouter } from "../../../src/trpc/routers/catalog";
type ProductStatus = "published" | "scheduled" | "unpublished";

/**
* Build a stable short suffix for test record names.
Expand Down Expand Up @@ -107,9 +82,9 @@ async function createProduct(options: {
brandId: string;
manufacturerId?: string | null;
name: string;
status: "published" | "scheduled" | "unpublished";
status: ProductStatus;
}): Promise<string> {
// Seed a product row that can participate in catalog fan-out lookups.
// Seed a product row that can participate in catalog dirty-marking lookups.
const productId = crypto.randomUUID();

await testDb.insert(schema.products).values({
Expand All @@ -127,35 +102,136 @@ async function createProduct(options: {
/**
* Insert a variant for the supplied product.
*/
async function createVariant(productId: string): Promise<string> {
// Create a variant row for variant-level material fan-out paths.
async function createVariant(productId: string): Promise<{
id: string;
upid: string;
sku: string;
barcode: string;
}> {
// Create a variant row plus identifiers that can be copied onto the passport.
const variantId = crypto.randomUUID();
const upid = `UPID-${randomSuffix()}`;
const sku = `SKU-${randomSuffix()}`;
const barcode = `BARCODE-${randomSuffix()}`;

await testDb.insert(schema.productVariants).values({
id: variantId,
productId,
sku: `SKU-${randomSuffix()}`,
upid: `UPID-${randomSuffix()}`,
sku,
barcode,
upid,
});

return variantId;
return {
id: variantId,
upid,
sku,
barcode,
};
}

/**
* Create a product, variant, and passport fixture in one helper call.
*/
async function createPassportFixture(options: {
brandId: string;
manufacturerId?: string | null;
name: string;
status: ProductStatus;
}): Promise<PassportFixture> {
// Keep the fixture setup concise for each catalog dirty-marking test.
const productId = await createProduct(options);
const variant = await createVariant(productId);
const passport = await createPassportForVariant(
testDb,
variant.id,
options.brandId,
{
upid: variant.upid,
sku: variant.sku,
barcode: variant.barcode,
},
);

if (!passport) {
throw new Error("Failed to create passport fixture");
}

return {
productId,
variantId: variant.id,
passportId: passport.id,
};
}

/**
* Load dirty-flag state for a set of passports.
*/
async function listPassportDirtyStates(passportIds: string[]) {
// Read the current dirty flags after a catalog mutation completes.
return testDb
.select({
id: schema.productPassports.id,
dirty: schema.productPassports.dirty,
})
.from(schema.productPassports)
.where(inArray(schema.productPassports.id, passportIds));
}

describe("Catalog Router Fan-Out", () => {
describe("Catalog Router Dirty Marking", () => {
let brandId: string;
let userEmail: string;
let userId: string;

beforeEach(async () => {
// Reset the queued trigger calls for each test case.
triggerCalls.length = 0;

brandId = await createTestBrand("Catalog Fan-Out Router Brand");
userEmail = `catalog-fan-out-${randomSuffix()}@example.com`;
// Create a fresh brand-scoped caller for each test case.
brandId = await createTestBrand("Catalog Dirty Router Brand");
userEmail = `catalog-dirty-${randomSuffix()}@example.com`;
userId = await createTestUser(userEmail);
await createBrandMembership(brandId, userId);
});

it("marks published manufacturer-linked passports dirty when updating a manufacturer", async () => {
// Update a manufacturer and ensure only published linked products are dirtied.
const manufacturerId = crypto.randomUUID();
await testDb.insert(schema.brandManufacturers).values({
id: manufacturerId,
brandId,
name: `Manufacturer ${randomSuffix()}`,
});

const publishedFixture = await createPassportFixture({
brandId,
manufacturerId,
name: "Published Manufacturer Product",
status: "published",
});
const unpublishedFixture = await createPassportFixture({
brandId,
manufacturerId,
name: "Unpublished Manufacturer Product",
status: "unpublished",
});

const ctx = createMockContext({ brandId, userEmail, userId });
await catalogRouter.createCaller(ctx).manufacturers.update({
id: manufacturerId,
name: `Updated Manufacturer ${randomSuffix()}`,
});

const dirtyRows = await listPassportDirtyStates([
publishedFixture.passportId,
unpublishedFixture.passportId,
]);

expect(
dirtyRows.find((row) => row.id === publishedFixture.passportId)?.dirty,
).toBe(true);
expect(
dirtyRows.find((row) => row.id === unpublishedFixture.passportId)?.dirty,
).toBe(false);
});

it("captures affected published products before deleting a manufacturer", async () => {
// Delete a manufacturer after linking it to both published and unpublished products.
const manufacturerId = crypto.randomUUID();
Expand All @@ -165,13 +241,13 @@ describe("Catalog Router Fan-Out", () => {
name: `Manufacturer ${randomSuffix()}`,
});

const publishedProductId = await createProduct({
const publishedFixture = await createPassportFixture({
brandId,
manufacturerId,
name: "Published Manufacturer Product",
status: "published",
});
await createProduct({
const unpublishedFixture = await createPassportFixture({
brandId,
manufacturerId,
name: "Unpublished Manufacturer Product",
Expand All @@ -183,27 +259,24 @@ describe("Catalog Router Fan-Out", () => {
id: manufacturerId,
});

expect(triggerCalls).toHaveLength(1);
expect(triggerCalls[0]).toEqual({
id: "catalog-fan-out",
payload: {
brandId,
entityType: "manufacturer",
entityId: manufacturerId,
productIds: [publishedProductId],
},
options: {
concurrencyKey: brandId,
delay: "45s",
},
});
const dirtyRows = await listPassportDirtyStates([
publishedFixture.passportId,
unpublishedFixture.passportId,
]);

expect(
dirtyRows.find((row) => row.id === publishedFixture.passportId)?.dirty,
).toBe(true);
expect(
dirtyRows.find((row) => row.id === unpublishedFixture.passportId)?.dirty,
).toBe(false);

const [product] = await testDb
const [publishedProduct] = await testDb
.select({ manufacturerId: schema.products.manufacturerId })
.from(schema.products)
.where(eq(schema.products.id, publishedProductId));
.where(eq(schema.products.id, publishedFixture.productId));

expect(product?.manufacturerId).toBeNull();
expect(publishedProduct?.manufacturerId).toBeNull();
});

it("captures published product and variant material references before deleting a certification", async () => {
Expand Down Expand Up @@ -233,36 +306,35 @@ describe("Catalog Router Fan-Out", () => {
},
]);

const productLinkedProductId = await createProduct({
const productLinkedFixture = await createPassportFixture({
brandId,
name: "Published Product Material Product",
status: "published",
});
const variantLinkedProductId = await createProduct({
const variantLinkedFixture = await createPassportFixture({
brandId,
name: "Published Variant Material Product",
status: "published",
});
const unpublishedProductId = await createProduct({
const unpublishedFixture = await createPassportFixture({
brandId,
name: "Unpublished Certification Product",
status: "unpublished",
});

await testDb.insert(schema.productMaterials).values([
{
productId: productLinkedProductId,
productId: productLinkedFixture.productId,
brandMaterialId: productMaterialId,
},
{
productId: unpublishedProductId,
productId: unpublishedFixture.productId,
brandMaterialId: productMaterialId,
},
]);

const variantId = await createVariant(variantLinkedProductId);
await testDb.insert(schema.variantMaterials).values({
variantId,
variantId: variantLinkedFixture.variantId,
brandMaterialId: variantMaterialId,
});

Expand All @@ -271,32 +343,31 @@ describe("Catalog Router Fan-Out", () => {
id: certificationId,
});

expect(triggerCalls).toHaveLength(1);
const dirtyRows = await listPassportDirtyStates([
productLinkedFixture.passportId,
variantLinkedFixture.passportId,
unpublishedFixture.passportId,
]);

const queuedProductIds = [...(triggerCalls[0]?.payload.productIds ?? [])].sort();
expect(triggerCalls[0]).toMatchObject({
id: "catalog-fan-out",
payload: {
brandId,
entityType: "certification",
entityId: certificationId,
},
options: {
concurrencyKey: brandId,
delay: "45s",
},
});
expect(queuedProductIds).toEqual(
[productLinkedProductId, variantLinkedProductId].sort(),
);
expect(
dirtyRows.find((row) => row.id === productLinkedFixture.passportId)?.dirty,
).toBe(true);
expect(
dirtyRows.find((row) => row.id === variantLinkedFixture.passportId)?.dirty,
).toBe(true);
expect(
dirtyRows.find((row) => row.id === unpublishedFixture.passportId)?.dirty,
).toBe(false);

const materials = await testDb
.select({
certificationId: schema.brandMaterials.certificationId,
id: schema.brandMaterials.id,
})
.from(schema.brandMaterials)
.where(eq(schema.brandMaterials.brandId, brandId));
.where(
inArray(schema.brandMaterials.id, [productMaterialId, variantMaterialId]),
);

expect(materials).toEqual(
expect.arrayContaining([
Expand Down
Loading
Loading