Skip to content

Commit 3ed0f5e

Browse files
authored
Merge pull request #125 from Avelero/feature/optimize-publishing
Feature/optimize publishing
2 parents 5eb17fb + bd802c3 commit 3ed0f5e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+36165
-1202
lines changed

apps/api/__tests__/integration/trpc/catalog-fan-out.test.ts

Lines changed: 165 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,28 @@
11
/**
2-
* Integration Tests: Catalog Router Fan-Out
2+
* Integration Tests: Catalog Router Dirty Marking
33
*
4-
* Verifies that catalog deletes enqueue background fan-out jobs with the
5-
* affected published product IDs captured before destructive FK updates.
4+
* Verifies that catalog mutations mark affected published passports dirty
5+
* inline instead of queueing a background fan-out task.
66
*/
77

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

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

17-
type TriggerCall = {
18-
id: string;
19-
payload: {
20-
brandId: string;
21-
entityType: string;
22-
entityId: string;
23-
productIds?: string[];
24-
};
25-
options?: {
26-
concurrencyKey?: string;
27-
delay?: string;
28-
};
19+
type PassportFixture = {
20+
productId: string;
21+
variantId: string;
22+
passportId: string;
2923
};
3024

31-
const triggerCalls: TriggerCall[] = [];
32-
33-
const triggerMock = mock(
34-
async (
35-
id: string,
36-
payload: TriggerCall["payload"],
37-
options?: TriggerCall["options"],
38-
) => {
39-
triggerCalls.push({ id, payload, options });
40-
return { id: `run_${triggerCalls.length}` } as const;
41-
},
42-
);
43-
44-
mock.module("@trigger.dev/sdk/v3", () => ({
45-
tasks: {
46-
trigger: triggerMock,
47-
},
48-
}));
49-
50-
import { catalogRouter } from "../../../src/trpc/routers/catalog";
25+
type ProductStatus = "published" | "scheduled" | "unpublished";
5126

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

11590
await testDb.insert(schema.products).values({
@@ -127,35 +102,136 @@ async function createProduct(options: {
127102
/**
128103
* Insert a variant for the supplied product.
129104
*/
130-
async function createVariant(productId: string): Promise<string> {
131-
// Create a variant row for variant-level material fan-out paths.
105+
async function createVariant(productId: string): Promise<{
106+
id: string;
107+
upid: string;
108+
sku: string;
109+
barcode: string;
110+
}> {
111+
// Create a variant row plus identifiers that can be copied onto the passport.
132112
const variantId = crypto.randomUUID();
113+
const upid = `UPID-${randomSuffix()}`;
114+
const sku = `SKU-${randomSuffix()}`;
115+
const barcode = `BARCODE-${randomSuffix()}`;
133116

134117
await testDb.insert(schema.productVariants).values({
135118
id: variantId,
136119
productId,
137-
sku: `SKU-${randomSuffix()}`,
138-
upid: `UPID-${randomSuffix()}`,
120+
sku,
121+
barcode,
122+
upid,
139123
});
140124

141-
return variantId;
125+
return {
126+
id: variantId,
127+
upid,
128+
sku,
129+
barcode,
130+
};
131+
}
132+
133+
/**
134+
* Create a product, variant, and passport fixture in one helper call.
135+
*/
136+
async function createPassportFixture(options: {
137+
brandId: string;
138+
manufacturerId?: string | null;
139+
name: string;
140+
status: ProductStatus;
141+
}): Promise<PassportFixture> {
142+
// Keep the fixture setup concise for each catalog dirty-marking test.
143+
const productId = await createProduct(options);
144+
const variant = await createVariant(productId);
145+
const passport = await createPassportForVariant(
146+
testDb,
147+
variant.id,
148+
options.brandId,
149+
{
150+
upid: variant.upid,
151+
sku: variant.sku,
152+
barcode: variant.barcode,
153+
},
154+
);
155+
156+
if (!passport) {
157+
throw new Error("Failed to create passport fixture");
158+
}
159+
160+
return {
161+
productId,
162+
variantId: variant.id,
163+
passportId: passport.id,
164+
};
165+
}
166+
167+
/**
168+
* Load dirty-flag state for a set of passports.
169+
*/
170+
async function listPassportDirtyStates(passportIds: string[]) {
171+
// Read the current dirty flags after a catalog mutation completes.
172+
return testDb
173+
.select({
174+
id: schema.productPassports.id,
175+
dirty: schema.productPassports.dirty,
176+
})
177+
.from(schema.productPassports)
178+
.where(inArray(schema.productPassports.id, passportIds));
142179
}
143180

144-
describe("Catalog Router Fan-Out", () => {
181+
describe("Catalog Router Dirty Marking", () => {
145182
let brandId: string;
146183
let userEmail: string;
147184
let userId: string;
148185

149186
beforeEach(async () => {
150-
// Reset the queued trigger calls for each test case.
151-
triggerCalls.length = 0;
152-
153-
brandId = await createTestBrand("Catalog Fan-Out Router Brand");
154-
userEmail = `catalog-fan-out-${randomSuffix()}@example.com`;
187+
// Create a fresh brand-scoped caller for each test case.
188+
brandId = await createTestBrand("Catalog Dirty Router Brand");
189+
userEmail = `catalog-dirty-${randomSuffix()}@example.com`;
155190
userId = await createTestUser(userEmail);
156191
await createBrandMembership(brandId, userId);
157192
});
158193

194+
it("marks published manufacturer-linked passports dirty when updating a manufacturer", async () => {
195+
// Update a manufacturer and ensure only published linked products are dirtied.
196+
const manufacturerId = crypto.randomUUID();
197+
await testDb.insert(schema.brandManufacturers).values({
198+
id: manufacturerId,
199+
brandId,
200+
name: `Manufacturer ${randomSuffix()}`,
201+
});
202+
203+
const publishedFixture = await createPassportFixture({
204+
brandId,
205+
manufacturerId,
206+
name: "Published Manufacturer Product",
207+
status: "published",
208+
});
209+
const unpublishedFixture = await createPassportFixture({
210+
brandId,
211+
manufacturerId,
212+
name: "Unpublished Manufacturer Product",
213+
status: "unpublished",
214+
});
215+
216+
const ctx = createMockContext({ brandId, userEmail, userId });
217+
await catalogRouter.createCaller(ctx).manufacturers.update({
218+
id: manufacturerId,
219+
name: `Updated Manufacturer ${randomSuffix()}`,
220+
});
221+
222+
const dirtyRows = await listPassportDirtyStates([
223+
publishedFixture.passportId,
224+
unpublishedFixture.passportId,
225+
]);
226+
227+
expect(
228+
dirtyRows.find((row) => row.id === publishedFixture.passportId)?.dirty,
229+
).toBe(true);
230+
expect(
231+
dirtyRows.find((row) => row.id === unpublishedFixture.passportId)?.dirty,
232+
).toBe(false);
233+
});
234+
159235
it("captures affected published products before deleting a manufacturer", async () => {
160236
// Delete a manufacturer after linking it to both published and unpublished products.
161237
const manufacturerId = crypto.randomUUID();
@@ -165,13 +241,13 @@ describe("Catalog Router Fan-Out", () => {
165241
name: `Manufacturer ${randomSuffix()}`,
166242
});
167243

168-
const publishedProductId = await createProduct({
244+
const publishedFixture = await createPassportFixture({
169245
brandId,
170246
manufacturerId,
171247
name: "Published Manufacturer Product",
172248
status: "published",
173249
});
174-
await createProduct({
250+
const unpublishedFixture = await createPassportFixture({
175251
brandId,
176252
manufacturerId,
177253
name: "Unpublished Manufacturer Product",
@@ -183,27 +259,24 @@ describe("Catalog Router Fan-Out", () => {
183259
id: manufacturerId,
184260
});
185261

186-
expect(triggerCalls).toHaveLength(1);
187-
expect(triggerCalls[0]).toEqual({
188-
id: "catalog-fan-out",
189-
payload: {
190-
brandId,
191-
entityType: "manufacturer",
192-
entityId: manufacturerId,
193-
productIds: [publishedProductId],
194-
},
195-
options: {
196-
concurrencyKey: brandId,
197-
delay: "45s",
198-
},
199-
});
262+
const dirtyRows = await listPassportDirtyStates([
263+
publishedFixture.passportId,
264+
unpublishedFixture.passportId,
265+
]);
266+
267+
expect(
268+
dirtyRows.find((row) => row.id === publishedFixture.passportId)?.dirty,
269+
).toBe(true);
270+
expect(
271+
dirtyRows.find((row) => row.id === unpublishedFixture.passportId)?.dirty,
272+
).toBe(false);
200273

201-
const [product] = await testDb
274+
const [publishedProduct] = await testDb
202275
.select({ manufacturerId: schema.products.manufacturerId })
203276
.from(schema.products)
204-
.where(eq(schema.products.id, publishedProductId));
277+
.where(eq(schema.products.id, publishedFixture.productId));
205278

206-
expect(product?.manufacturerId).toBeNull();
279+
expect(publishedProduct?.manufacturerId).toBeNull();
207280
});
208281

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

236-
const productLinkedProductId = await createProduct({
309+
const productLinkedFixture = await createPassportFixture({
237310
brandId,
238311
name: "Published Product Material Product",
239312
status: "published",
240313
});
241-
const variantLinkedProductId = await createProduct({
314+
const variantLinkedFixture = await createPassportFixture({
242315
brandId,
243316
name: "Published Variant Material Product",
244317
status: "published",
245318
});
246-
const unpublishedProductId = await createProduct({
319+
const unpublishedFixture = await createPassportFixture({
247320
brandId,
248321
name: "Unpublished Certification Product",
249322
status: "unpublished",
250323
});
251324

252325
await testDb.insert(schema.productMaterials).values([
253326
{
254-
productId: productLinkedProductId,
327+
productId: productLinkedFixture.productId,
255328
brandMaterialId: productMaterialId,
256329
},
257330
{
258-
productId: unpublishedProductId,
331+
productId: unpublishedFixture.productId,
259332
brandMaterialId: productMaterialId,
260333
},
261334
]);
262335

263-
const variantId = await createVariant(variantLinkedProductId);
264336
await testDb.insert(schema.variantMaterials).values({
265-
variantId,
337+
variantId: variantLinkedFixture.variantId,
266338
brandMaterialId: variantMaterialId,
267339
});
268340

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

274-
expect(triggerCalls).toHaveLength(1);
346+
const dirtyRows = await listPassportDirtyStates([
347+
productLinkedFixture.passportId,
348+
variantLinkedFixture.passportId,
349+
unpublishedFixture.passportId,
350+
]);
275351

276-
const queuedProductIds = [...(triggerCalls[0]?.payload.productIds ?? [])].sort();
277-
expect(triggerCalls[0]).toMatchObject({
278-
id: "catalog-fan-out",
279-
payload: {
280-
brandId,
281-
entityType: "certification",
282-
entityId: certificationId,
283-
},
284-
options: {
285-
concurrencyKey: brandId,
286-
delay: "45s",
287-
},
288-
});
289-
expect(queuedProductIds).toEqual(
290-
[productLinkedProductId, variantLinkedProductId].sort(),
291-
);
352+
expect(
353+
dirtyRows.find((row) => row.id === productLinkedFixture.passportId)?.dirty,
354+
).toBe(true);
355+
expect(
356+
dirtyRows.find((row) => row.id === variantLinkedFixture.passportId)?.dirty,
357+
).toBe(true);
358+
expect(
359+
dirtyRows.find((row) => row.id === unpublishedFixture.passportId)?.dirty,
360+
).toBe(false);
292361

293362
const materials = await testDb
294363
.select({
295364
certificationId: schema.brandMaterials.certificationId,
296365
id: schema.brandMaterials.id,
297366
})
298367
.from(schema.brandMaterials)
299-
.where(eq(schema.brandMaterials.brandId, brandId));
368+
.where(
369+
inArray(schema.brandMaterials.id, [productMaterialId, variantMaterialId]),
370+
);
300371

301372
expect(materials).toEqual(
302373
expect.arrayContaining([

0 commit comments

Comments
 (0)