Skip to content

Commit 6cbb3de

Browse files
committed
test(migrations): cover include-by-default snapshot rewrite
Validates the migration rewrites the price sentinel in Subscription, OneTimePurchase, and ProductVersion snapshots while preserving existing includedItems and leaving real-priced rows untouched.
1 parent 5279a49 commit 6cbb3de

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

  • apps/backend/prisma/migrations/20260421000000_drop_include_by_default_snapshots/tests
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { randomUUID } from 'crypto';
2+
import type { Sql } from 'postgres';
3+
import { expect } from 'vitest';
4+
5+
/**
6+
* Migration-level test for `20260421000000_drop_include_by_default_snapshots`.
7+
*
8+
* The migration's job is to rewrite historical product JSON snapshots in
9+
* three tables (`Subscription`, `OneTimePurchase`, `ProductVersion`) so that
10+
* the legacy `"include-by-default"` price sentinel is replaced with an empty
11+
* price record, and any missing `includedItems` field is filled in with `{}`
12+
* (downstream readers like `mapProductSnapshotToInlineProduct` assume both
13+
* fields exist as records).
14+
*
15+
* Edge cases covered:
16+
* 1. `Subscription`: sentinel + missing `includedItems` → prices `{}`, items `{}`.
17+
* 2. `Subscription`: sentinel + existing `includedItems` → items preserved.
18+
* 3. `Subscription`: NO sentinel (real prices) → row untouched.
19+
* 4. `OneTimePurchase`: sentinel → migrated identically to Subscription.
20+
* 5. `ProductVersion`: sentinel (in `productJson` not `product`) → migrated.
21+
*
22+
* `tenancyId` on these tables is a UUID column without an enforced FK to
23+
* `Tenancy`, so we can use random UUIDs without seeding the parent rows.
24+
*/
25+
26+
type Ctx = {
27+
// Subscription IDs
28+
subSentinelMissingItemsId: string,
29+
subSentinelWithItemsId: string,
30+
subRealPricesId: string,
31+
subSentinelMissingItemsTenancy: string,
32+
subSentinelWithItemsTenancy: string,
33+
subRealPricesTenancy: string,
34+
// OneTimePurchase
35+
otpId: string,
36+
otpTenancy: string,
37+
// ProductVersion
38+
pvProductVersionId: string,
39+
pvTenancy: string,
40+
};
41+
42+
export const preMigration = async (sql: Sql): Promise<Ctx> => {
43+
const ctx: Ctx = {
44+
subSentinelMissingItemsId: randomUUID(),
45+
subSentinelWithItemsId: randomUUID(),
46+
subRealPricesId: randomUUID(),
47+
subSentinelMissingItemsTenancy: randomUUID(),
48+
subSentinelWithItemsTenancy: randomUUID(),
49+
subRealPricesTenancy: randomUUID(),
50+
otpId: randomUUID(),
51+
otpTenancy: randomUUID(),
52+
pvProductVersionId: `pv-${randomUUID()}`,
53+
pvTenancy: randomUUID(),
54+
};
55+
56+
// Case 1: Subscription with sentinel + no includedItems field at all.
57+
// `updatedAt` must be set explicitly — Prisma's `@updatedAt` annotation is
58+
// client-side, raw SQL inserts skip it and the column is NOT NULL.
59+
await sql`
60+
INSERT INTO "Subscription" (
61+
"id", "tenancyId", "customerId", "customerType",
62+
"productId", "priceId", "product", "quantity",
63+
"status", "currentPeriodStart", "currentPeriodEnd",
64+
"cancelAtPeriodEnd", "creationSource", "updatedAt"
65+
) VALUES (
66+
${ctx.subSentinelMissingItemsId}::uuid,
67+
${ctx.subSentinelMissingItemsTenancy}::uuid,
68+
'customer-1', 'TEAM',
69+
'legacy-default', NULL,
70+
${sql.json({
71+
displayName: 'Legacy Default',
72+
customerType: 'team',
73+
prices: 'include-by-default',
74+
})},
75+
1,
76+
'active'::"SubscriptionStatus",
77+
NOW(),
78+
NOW() + interval '30 days',
79+
false,
80+
'API_GRANT'::"PurchaseCreationSource",
81+
NOW()
82+
)
83+
`;
84+
85+
// Case 2: Subscription with sentinel + already-populated includedItems.
86+
// The migration must NOT overwrite this — it only fills in when missing.
87+
await sql`
88+
INSERT INTO "Subscription" (
89+
"id", "tenancyId", "customerId", "customerType",
90+
"productId", "priceId", "product", "quantity",
91+
"status", "currentPeriodStart", "currentPeriodEnd",
92+
"cancelAtPeriodEnd", "creationSource", "updatedAt"
93+
) VALUES (
94+
${ctx.subSentinelWithItemsId}::uuid,
95+
${ctx.subSentinelWithItemsTenancy}::uuid,
96+
'customer-2', 'TEAM',
97+
'legacy-default-2', NULL,
98+
${sql.json({
99+
displayName: 'Legacy Default With Items',
100+
customerType: 'team',
101+
prices: 'include-by-default',
102+
includedItems: {
103+
'item-a': { quantity: 5, repeat: 'never', expires: 'never' },
104+
},
105+
})},
106+
1,
107+
'active'::"SubscriptionStatus",
108+
NOW(),
109+
NOW() + interval '30 days',
110+
false,
111+
'API_GRANT'::"PurchaseCreationSource",
112+
NOW()
113+
)
114+
`;
115+
116+
// Case 3: Subscription with REAL prices — must remain untouched.
117+
await sql`
118+
INSERT INTO "Subscription" (
119+
"id", "tenancyId", "customerId", "customerType",
120+
"productId", "priceId", "product", "quantity",
121+
"status", "currentPeriodStart", "currentPeriodEnd",
122+
"cancelAtPeriodEnd", "creationSource", "updatedAt"
123+
) VALUES (
124+
${ctx.subRealPricesId}::uuid,
125+
${ctx.subRealPricesTenancy}::uuid,
126+
'customer-3', 'USER',
127+
'paid-plan', 'monthly',
128+
${sql.json({
129+
displayName: 'Paid Plan',
130+
customerType: 'user',
131+
prices: {
132+
monthly: { USD: '10.00', interval: [1, 'month'], serverOnly: false },
133+
},
134+
includedItems: {},
135+
})},
136+
1,
137+
'active'::"SubscriptionStatus",
138+
NOW(),
139+
NOW() + interval '30 days',
140+
false,
141+
'PURCHASE_PAGE'::"PurchaseCreationSource",
142+
NOW()
143+
)
144+
`;
145+
146+
// Case 4: OneTimePurchase with sentinel.
147+
await sql`
148+
INSERT INTO "OneTimePurchase" (
149+
"id", "tenancyId", "customerId", "customerType",
150+
"productId", "priceId", "product", "quantity",
151+
"creationSource"
152+
) VALUES (
153+
${ctx.otpId}::uuid,
154+
${ctx.otpTenancy}::uuid,
155+
'customer-4', 'USER',
156+
'legacy-otp', NULL,
157+
${sql.json({
158+
displayName: 'Legacy OTP',
159+
customerType: 'user',
160+
prices: 'include-by-default',
161+
})},
162+
1,
163+
'API_GRANT'::"PurchaseCreationSource"
164+
)
165+
`;
166+
167+
// Case 5: ProductVersion with sentinel (note: column is `productJson`, not `product`).
168+
await sql`
169+
INSERT INTO "ProductVersion" (
170+
"tenancyId", "productVersionId", "productId", "productJson"
171+
) VALUES (
172+
${ctx.pvTenancy}::uuid,
173+
${ctx.pvProductVersionId},
174+
'legacy-pv',
175+
${sql.json({
176+
displayName: 'Legacy PV',
177+
customerType: 'team',
178+
prices: 'include-by-default',
179+
})}
180+
)
181+
`;
182+
183+
return ctx;
184+
};
185+
186+
export const postMigration = async (sql: Sql, ctx: Ctx) => {
187+
// ---- Case 1 ----
188+
const sub1 = await sql<Array<{ product: unknown }>>`
189+
SELECT "product" FROM "Subscription"
190+
WHERE "id" = ${ctx.subSentinelMissingItemsId}::uuid
191+
`;
192+
expect(sub1).toHaveLength(1);
193+
expect(sub1[0].product).toEqual({
194+
displayName: 'Legacy Default',
195+
customerType: 'team',
196+
prices: {},
197+
includedItems: {},
198+
});
199+
200+
// ---- Case 2 ----
201+
const sub2 = await sql<Array<{ product: unknown }>>`
202+
SELECT "product" FROM "Subscription"
203+
WHERE "id" = ${ctx.subSentinelWithItemsId}::uuid
204+
`;
205+
expect(sub2).toHaveLength(1);
206+
expect(sub2[0].product).toEqual({
207+
displayName: 'Legacy Default With Items',
208+
customerType: 'team',
209+
prices: {},
210+
includedItems: {
211+
'item-a': { quantity: 5, repeat: 'never', expires: 'never' },
212+
},
213+
});
214+
215+
// ---- Case 3 (regression guard: don't touch real-price rows) ----
216+
const sub3 = await sql<Array<{ product: unknown }>>`
217+
SELECT "product" FROM "Subscription"
218+
WHERE "id" = ${ctx.subRealPricesId}::uuid
219+
`;
220+
expect(sub3).toHaveLength(1);
221+
expect(sub3[0].product).toEqual({
222+
displayName: 'Paid Plan',
223+
customerType: 'user',
224+
prices: {
225+
monthly: { USD: '10.00', interval: [1, 'month'], serverOnly: false },
226+
},
227+
includedItems: {},
228+
});
229+
230+
// ---- Case 4 ----
231+
const otp = await sql<Array<{ product: unknown }>>`
232+
SELECT "product" FROM "OneTimePurchase"
233+
WHERE "id" = ${ctx.otpId}::uuid
234+
`;
235+
expect(otp).toHaveLength(1);
236+
expect(otp[0].product).toEqual({
237+
displayName: 'Legacy OTP',
238+
customerType: 'user',
239+
prices: {},
240+
includedItems: {},
241+
});
242+
243+
// ---- Case 5 ----
244+
const pv = await sql<Array<{ productJson: unknown }>>`
245+
SELECT "productJson" FROM "ProductVersion"
246+
WHERE "tenancyId" = ${ctx.pvTenancy}::uuid
247+
AND "productVersionId" = ${ctx.pvProductVersionId}
248+
`;
249+
expect(pv).toHaveLength(1);
250+
expect(pv[0].productJson).toEqual({
251+
displayName: 'Legacy PV',
252+
customerType: 'team',
253+
prices: {},
254+
includedItems: {},
255+
});
256+
257+
// ---- Cross-table sanity: no row anywhere still has the sentinel ----
258+
const remainingSubs = await sql`
259+
SELECT 1 FROM "Subscription" WHERE "product"->>'prices' = 'include-by-default'
260+
`;
261+
const remainingOtps = await sql`
262+
SELECT 1 FROM "OneTimePurchase" WHERE "product"->>'prices' = 'include-by-default'
263+
`;
264+
const remainingPvs = await sql`
265+
SELECT 1 FROM "ProductVersion" WHERE "productJson"->>'prices' = 'include-by-default'
266+
`;
267+
expect(remainingSubs).toHaveLength(0);
268+
expect(remainingOtps).toHaveLength(0);
269+
expect(remainingPvs).toHaveLength(0);
270+
};

0 commit comments

Comments
 (0)