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)
99import "../../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" ;
1213import * as schema from "@v1/db/schema" ;
1314import { createTestBrand , createTestUser , testDb } from "@v1/db/testing" ;
14- import { eq } from "drizzle-orm" ;
15+ import { eq , inArray } from "drizzle-orm" ;
1516import 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