|
1 | 1 | /** |
2 | 2 | * Verifies the Temporal `reconcileCleanup` activity tombstones rows for |
3 | | - * records that were hard-deleted in Stripe without the corresponding |
4 | | - * `*.deleted` event being processed — the "missed delete" path that |
5 | | - * complements stripe-delete.test.ts (the event-driven path). |
6 | | - * |
7 | | - * Seeds destination rows via in-process engine, then runs the production |
8 | | - * activity through `MockActivityEnvironment` so the composition |
9 | | - * (`pg.getStaleRecords` → `stripe.verifyRecords` → `pg.write`) is exercised |
10 | | - * end-to-end with a Temporal Activity Context active (heartbeats become no-ops). |
| 3 | + * records hard-deleted in Stripe without a `*.deleted` event — the "missed |
| 4 | + * delete" path complementing stripe-delete.test.ts. Two suites (postgres, |
| 5 | + * google_sheets) run the production activity via `MockActivityEnvironment`. |
11 | 6 | */ |
12 | 7 | import pg from 'pg' |
13 | 8 | import Stripe from 'stripe' |
| 9 | +import { google } from 'googleapis' |
14 | 10 | import { afterAll, beforeAll, expect, it } from 'vitest' |
15 | 11 | import { MockActivityEnvironment } from '@temporalio/testing' |
16 | 12 | import source from '@stripe/sync-source-stripe' |
17 | 13 | import destinationPostgres from '@stripe/sync-destination-postgres' |
| 14 | +import destinationSheets, { readSheet } from '@stripe/sync-destination-google-sheets' |
18 | 15 | import { createEngine } from '@stripe/sync-engine' |
19 | 16 | import type { ConnectorResolver } from '@stripe/sync-engine' |
20 | 17 | import { createActivities } from '@stripe/sync-service' |
@@ -129,9 +126,7 @@ describeWithEnv( |
129 | 126 | try { |
130 | 127 | // Backfill-only sync (no websocket, no event polling) — both rows |
131 | 128 | // land in postgres with `_synced_at ≈ T0`. |
132 | | - for await (const _msg of engine.pipeline_sync(pipeline)) { |
133 | | - void _msg |
134 | | - } |
| 129 | + await drain(engine.pipeline_sync(pipeline)) |
135 | 130 |
|
136 | 131 | const seeded = await pool.query<{ id: string }>( |
137 | 132 | `SELECT id FROM "${SCHEMA}"."${STREAM}" WHERE id = ANY($1)`, |
@@ -177,3 +172,138 @@ describeWithEnv( |
177 | 172 | }, 180_000) |
178 | 173 | } |
179 | 174 | ) |
| 175 | + |
| 176 | +// MARK: - Google Sheets |
| 177 | + |
| 178 | +describeWithEnv( |
| 179 | + 'temporal reconcile-cleanup activity → google sheets (missed delete)', |
| 180 | + ['STRIPE_API_KEY', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REFRESH_TOKEN'], |
| 181 | + ({ STRIPE_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN }) => { |
| 182 | + const PIPELINE_ID = `pipe_recon_sheets_${ts}` |
| 183 | + let stripe: Stripe |
| 184 | + let sheetsClient: ReturnType<typeof google.sheets> |
| 185 | + let driveClient: ReturnType<typeof google.drive> |
| 186 | + let spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID ?? '' |
| 187 | + let createdSpreadsheetHere = false |
| 188 | + |
| 189 | + const sourceConfig = { api_key: STRIPE_API_KEY, backfill_limit: BACKFILL_LIMIT } |
| 190 | + |
| 191 | + const resolver: ConnectorResolver = { |
| 192 | + resolveSource: async (name) => { |
| 193 | + if (name !== 'stripe') throw new Error(`Unknown source: ${name}`) |
| 194 | + return source |
| 195 | + }, |
| 196 | + resolveDestination: async (name) => { |
| 197 | + if (name !== 'google_sheets') throw new Error(`Unknown destination: ${name}`) |
| 198 | + return destinationSheets |
| 199 | + }, |
| 200 | + sources: () => new Map(), |
| 201 | + destinations: () => new Map(), |
| 202 | + } |
| 203 | + |
| 204 | + function makePipeline() { |
| 205 | + return { |
| 206 | + source: { type: 'stripe', stripe: sourceConfig }, |
| 207 | + destination: { |
| 208 | + type: 'google_sheets', |
| 209 | + google_sheets: { |
| 210 | + client_id: GOOGLE_CLIENT_ID, |
| 211 | + client_secret: GOOGLE_CLIENT_SECRET, |
| 212 | + refresh_token: GOOGLE_REFRESH_TOKEN, |
| 213 | + ...(spreadsheetId ? { spreadsheet_id: spreadsheetId } : {}), |
| 214 | + spreadsheet_title: `e2e-recon-sheets-${ts}`, |
| 215 | + batch_size: 50, |
| 216 | + }, |
| 217 | + }, |
| 218 | + streams: [{ name: STREAM }], |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + beforeAll(async () => { |
| 223 | + stripe = new Stripe(STRIPE_API_KEY) |
| 224 | + const auth = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) |
| 225 | + auth.setCredentials({ refresh_token: GOOGLE_REFRESH_TOKEN }) |
| 226 | + sheetsClient = google.sheets({ version: 'v4', auth }) |
| 227 | + driveClient = google.drive({ version: 'v3', auth }) |
| 228 | + }) |
| 229 | + |
| 230 | + afterAll(async () => { |
| 231 | + if (createdSpreadsheetHere && spreadsheetId && !process.env.KEEP_TEST_DATA) { |
| 232 | + try { |
| 233 | + await driveClient.files.delete({ fileId: spreadsheetId }) |
| 234 | + } catch {} |
| 235 | + } |
| 236 | + }) |
| 237 | + |
| 238 | + it('tombstones customers deleted in stripe without a delete event', async () => { |
| 239 | + const engine = await createEngine(resolver) |
| 240 | + |
| 241 | + // pipeline_setup creates the spreadsheet if needed and emits the new |
| 242 | + // id via destination_config — capture so the second pipeline run reuses it. |
| 243 | + for await (const m of engine.pipeline_setup(makePipeline())) { |
| 244 | + if ( |
| 245 | + m.type === 'control' && |
| 246 | + m.control.control_type === 'destination_config' && |
| 247 | + typeof m.control.destination_config.spreadsheet_id === 'string' && |
| 248 | + m.control.destination_config.spreadsheet_id !== spreadsheetId |
| 249 | + ) { |
| 250 | + spreadsheetId = m.control.destination_config.spreadsheet_id |
| 251 | + createdSpreadsheetHere = true |
| 252 | + } |
| 253 | + } |
| 254 | + expect(spreadsheetId, 'no spreadsheet_id available (env or destination)').toBeTruthy() |
| 255 | + console.log(`\n Sheets: https://docs.google.com/spreadsheets/d/${spreadsheetId}/`) |
| 256 | + console.log(` Pipeline: ${PIPELINE_ID}`) |
| 257 | + |
| 258 | + const pipeline = makePipeline() |
| 259 | + const pipelineStore = memoryPipelineStore() |
| 260 | + await pipelineStore.set(PIPELINE_ID, { id: PIPELINE_ID, ...pipeline } as Pipeline) |
| 261 | + |
| 262 | + const survivor = await stripe.customers.create({ |
| 263 | + name: `e2e-recon-sheets-survivor-${Date.now()}`, |
| 264 | + }) |
| 265 | + const doomed = await stripe.customers.create({ |
| 266 | + name: `e2e-recon-sheets-doomed-${Date.now()}`, |
| 267 | + }) |
| 268 | + const cleanupIds = new Set<string>([survivor.id, doomed.id]) |
| 269 | + |
| 270 | + try { |
| 271 | + // Backfill seeds both customers with `_synced_at ≈ T0`. |
| 272 | + await drain(engine.pipeline_sync(pipeline)) |
| 273 | + |
| 274 | + const seededRows = await readSheet(sheetsClient, spreadsheetId, STREAM) |
| 275 | + const seededHeader = (seededRows[0] ?? []) as string[] |
| 276 | + const idIdx = seededHeader.indexOf('id') |
| 277 | + expect(idIdx, 'id column missing in sheet header').toBeGreaterThanOrEqual(0) |
| 278 | + const seededIds = new Set(seededRows.slice(1).map((row) => String(row[idIdx] ?? ''))) |
| 279 | + expect(seededIds.has(survivor.id)).toBe(true) |
| 280 | + expect(seededIds.has(doomed.id)).toBe(true) |
| 281 | + |
| 282 | + await stripe.customers.del(doomed.id) |
| 283 | + cleanupIds.delete(doomed.id) |
| 284 | + |
| 285 | + await new Promise((r) => setTimeout(r, 50)) |
| 286 | + const syncRunStartedAt = new Date().toISOString() |
| 287 | + |
| 288 | + const activities = createActivities({ engineUrl: 'http://unused', pipelineStore }) |
| 289 | + const env = new MockActivityEnvironment() |
| 290 | + await env.run(activities.reconcileCleanup, PIPELINE_ID, syncRunStartedAt) |
| 291 | + |
| 292 | + const afterRows = await readSheet(sheetsClient, spreadsheetId, STREAM) |
| 293 | + const afterIds = new Set(afterRows.slice(1).map((row) => String(row[idIdx] ?? ''))) |
| 294 | + expect(afterIds.has(survivor.id), `survivor ${survivor.id} was tombstoned`).toBe(true) |
| 295 | + expect(afterIds.has(doomed.id), `doomed ${doomed.id} was not tombstoned`).toBe(false) |
| 296 | + console.log(` Survived: ${survivor.id}`) |
| 297 | + console.log(` Tombstoned: ${doomed.id}`) |
| 298 | + } finally { |
| 299 | + if (!process.env.KEEP_TEST_DATA) { |
| 300 | + for (const id of cleanupIds) { |
| 301 | + try { |
| 302 | + await stripe.customers.del(id) |
| 303 | + } catch {} |
| 304 | + } |
| 305 | + } |
| 306 | + } |
| 307 | + }, 240_000) |
| 308 | + } |
| 309 | +) |
0 commit comments