Skip to content

Commit 5eb17fb

Browse files
authored
Merge pull request #124 from Avelero/feature/bug-sweep
Feature/bug sweep
2 parents 0764831 + 1ce0cc3 commit 5eb17fb

File tree

55 files changed

+2315
-276
lines changed

Some content is hidden

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

55 files changed

+2315
-276
lines changed

.github/workflows/production-jobs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ jobs:
4040
INTERNAL_API_KEY: ${{ secrets.INTERNAL_API_KEY }}
4141
DATABASE_URL: ${{ secrets.DATABASE_URL }}
4242
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
43+
SUPABASE_STORAGE_URL: ${{ secrets.SUPABASE_STORAGE_URL }}
4344
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
4445
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}

apps/admin/complete.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ NEXT_PUBLIC_API_URL=http://localhost:4000
44
# Supabase
55
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
66
NEXT_PUBLIC_SUPABASE_ANON_KEY=
7+
NEXT_PUBLIC_STORAGE_URL=
78

89
# Google OAuth (for Google sign-in button)
910
NEXT_PUBLIC_GOOGLE_CLIENT_ID=

apps/admin/next.config.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
/**
2+
* Next.js configuration for the admin frontend.
3+
*/
14
import "./src/env.mjs";
25

36
/** @type {import('next').NextConfig} */
47
const supabaseUrl = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL);
8+
const storageUrl = new URL(
9+
process.env.NEXT_PUBLIC_STORAGE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL,
10+
);
511

612
const isLocal =
7-
supabaseUrl.hostname === "127.0.0.1" || supabaseUrl.hostname === "localhost";
13+
storageUrl.hostname === "127.0.0.1" || storageUrl.hostname === "localhost";
814

915
/** @type {import('next').NextConfig} */
1016
const nextConfig = {
@@ -13,6 +19,12 @@ const nextConfig = {
1319
images: {
1420
unoptimized: isLocal,
1521
remotePatterns: [
22+
{
23+
protocol: storageUrl.protocol.replace(":", ""),
24+
hostname: storageUrl.hostname,
25+
port: storageUrl.port,
26+
pathname: "/storage/**",
27+
},
1628
{
1729
protocol: supabaseUrl.protocol.replace(":", ""),
1830
hostname: supabaseUrl.hostname,

apps/admin/src/env.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/**
2+
* Environment schema and runtime bindings for the admin frontend.
3+
*/
14
import { createEnv } from "@t3-oss/env-nextjs";
25
import { z } from "zod";
36

@@ -16,13 +19,15 @@ export const env = createEnv({
1619
NEXT_PUBLIC_API_URL: z.string().min(1),
1720
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
1821
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
22+
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
1923
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().min(1),
2024
NEXT_PUBLIC_APP_URL: z.string().url(),
2125
},
2226
runtimeEnv: {
2327
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
2428
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
2529
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
30+
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
2631
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
2732
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
2833
SUPABASE_SERVICE_KEY: process.env.SUPABASE_SERVICE_KEY,
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/**
2+
* Integration Tests: Catalog Router Fan-Out
3+
*
4+
* Verifies that catalog deletes enqueue background fan-out jobs with the
5+
* affected published product IDs captured before destructive FK updates.
6+
*/
7+
8+
// Load setup first (loads .env.test and configures cleanup)
9+
import "../../setup";
10+
11+
import { beforeEach, describe, expect, it, mock } from "bun:test";
12+
import * as schema from "@v1/db/schema";
13+
import { createTestBrand, createTestUser, testDb } from "@v1/db/testing";
14+
import { eq } from "drizzle-orm";
15+
import type { AuthenticatedTRPCContext } from "../../../src/trpc/init";
16+
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+
};
29+
};
30+
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";
51+
52+
/**
53+
* Build a stable short suffix for test record names.
54+
*/
55+
function randomSuffix(): string {
56+
// Keep handles and names unique across test cases.
57+
return Math.random().toString(36).slice(2, 10);
58+
}
59+
60+
/**
61+
* Create a mock authenticated tRPC context for catalog router calls.
62+
*/
63+
function createMockContext(options: {
64+
brandId: string;
65+
userEmail: string;
66+
userId: string;
67+
}): AuthenticatedTRPCContext & { brandId: string } {
68+
// Provide the minimum authenticated shape needed by the router middleware.
69+
return {
70+
user: {
71+
id: options.userId,
72+
email: options.userEmail,
73+
app_metadata: {},
74+
user_metadata: {},
75+
aud: "authenticated",
76+
created_at: new Date().toISOString(),
77+
} as any,
78+
brandId: options.brandId,
79+
role: "owner",
80+
db: testDb,
81+
loaders: {} as any,
82+
supabase: {} as any,
83+
supabaseAdmin: null,
84+
geo: { ip: null },
85+
};
86+
}
87+
88+
/**
89+
* Create a brand membership for the test user.
90+
*/
91+
async function createBrandMembership(
92+
brandId: string,
93+
userId: string,
94+
): Promise<void> {
95+
// Authorize the caller against the brand-scoped procedures.
96+
await testDb.insert(schema.brandMembers).values({
97+
brandId,
98+
userId,
99+
role: "owner",
100+
});
101+
}
102+
103+
/**
104+
* Insert a product with an optional manufacturer link.
105+
*/
106+
async function createProduct(options: {
107+
brandId: string;
108+
manufacturerId?: string | null;
109+
name: string;
110+
status: "published" | "scheduled" | "unpublished";
111+
}): Promise<string> {
112+
// Seed a product row that can participate in catalog fan-out lookups.
113+
const productId = crypto.randomUUID();
114+
115+
await testDb.insert(schema.products).values({
116+
id: productId,
117+
brandId: options.brandId,
118+
manufacturerId: options.manufacturerId ?? null,
119+
name: options.name,
120+
productHandle: `product-${randomSuffix()}`,
121+
status: options.status,
122+
});
123+
124+
return productId;
125+
}
126+
127+
/**
128+
* Insert a variant for the supplied product.
129+
*/
130+
async function createVariant(productId: string): Promise<string> {
131+
// Create a variant row for variant-level material fan-out paths.
132+
const variantId = crypto.randomUUID();
133+
134+
await testDb.insert(schema.productVariants).values({
135+
id: variantId,
136+
productId,
137+
sku: `SKU-${randomSuffix()}`,
138+
upid: `UPID-${randomSuffix()}`,
139+
});
140+
141+
return variantId;
142+
}
143+
144+
describe("Catalog Router Fan-Out", () => {
145+
let brandId: string;
146+
let userEmail: string;
147+
let userId: string;
148+
149+
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`;
155+
userId = await createTestUser(userEmail);
156+
await createBrandMembership(brandId, userId);
157+
});
158+
159+
it("captures affected published products before deleting a manufacturer", async () => {
160+
// Delete a manufacturer after linking it to both published and unpublished products.
161+
const manufacturerId = crypto.randomUUID();
162+
await testDb.insert(schema.brandManufacturers).values({
163+
id: manufacturerId,
164+
brandId,
165+
name: `Manufacturer ${randomSuffix()}`,
166+
});
167+
168+
const publishedProductId = await createProduct({
169+
brandId,
170+
manufacturerId,
171+
name: "Published Manufacturer Product",
172+
status: "published",
173+
});
174+
await createProduct({
175+
brandId,
176+
manufacturerId,
177+
name: "Unpublished Manufacturer Product",
178+
status: "unpublished",
179+
});
180+
181+
const ctx = createMockContext({ brandId, userEmail, userId });
182+
await catalogRouter.createCaller(ctx).manufacturers.delete({
183+
id: manufacturerId,
184+
});
185+
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+
});
200+
201+
const [product] = await testDb
202+
.select({ manufacturerId: schema.products.manufacturerId })
203+
.from(schema.products)
204+
.where(eq(schema.products.id, publishedProductId));
205+
206+
expect(product?.manufacturerId).toBeNull();
207+
});
208+
209+
it("captures published product and variant material references before deleting a certification", async () => {
210+
// Delete a certification after linking it through both product and variant materials.
211+
const certificationId = crypto.randomUUID();
212+
const productMaterialId = crypto.randomUUID();
213+
const variantMaterialId = crypto.randomUUID();
214+
215+
await testDb.insert(schema.brandCertifications).values({
216+
id: certificationId,
217+
brandId,
218+
title: `Certification ${randomSuffix()}`,
219+
});
220+
221+
await testDb.insert(schema.brandMaterials).values([
222+
{
223+
id: productMaterialId,
224+
brandId,
225+
name: `Product Material ${randomSuffix()}`,
226+
certificationId,
227+
},
228+
{
229+
id: variantMaterialId,
230+
brandId,
231+
name: `Variant Material ${randomSuffix()}`,
232+
certificationId,
233+
},
234+
]);
235+
236+
const productLinkedProductId = await createProduct({
237+
brandId,
238+
name: "Published Product Material Product",
239+
status: "published",
240+
});
241+
const variantLinkedProductId = await createProduct({
242+
brandId,
243+
name: "Published Variant Material Product",
244+
status: "published",
245+
});
246+
const unpublishedProductId = await createProduct({
247+
brandId,
248+
name: "Unpublished Certification Product",
249+
status: "unpublished",
250+
});
251+
252+
await testDb.insert(schema.productMaterials).values([
253+
{
254+
productId: productLinkedProductId,
255+
brandMaterialId: productMaterialId,
256+
},
257+
{
258+
productId: unpublishedProductId,
259+
brandMaterialId: productMaterialId,
260+
},
261+
]);
262+
263+
const variantId = await createVariant(variantLinkedProductId);
264+
await testDb.insert(schema.variantMaterials).values({
265+
variantId,
266+
brandMaterialId: variantMaterialId,
267+
});
268+
269+
const ctx = createMockContext({ brandId, userEmail, userId });
270+
await catalogRouter.createCaller(ctx).certifications.delete({
271+
id: certificationId,
272+
});
273+
274+
expect(triggerCalls).toHaveLength(1);
275+
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+
);
292+
293+
const materials = await testDb
294+
.select({
295+
certificationId: schema.brandMaterials.certificationId,
296+
id: schema.brandMaterials.id,
297+
})
298+
.from(schema.brandMaterials)
299+
.where(eq(schema.brandMaterials.brandId, brandId));
300+
301+
expect(materials).toEqual(
302+
expect.arrayContaining([
303+
{ id: productMaterialId, certificationId: null },
304+
{ id: variantMaterialId, certificationId: null },
305+
]),
306+
);
307+
});
308+
});

0 commit comments

Comments
 (0)