diff --git a/packages/source-stripe/src/process-event.test.ts b/packages/source-stripe/src/process-event.test.ts new file mode 100644 index 000000000..04867c46c --- /dev/null +++ b/packages/source-stripe/src/process-event.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import { fromStripeEvent, processStripeEvent } from './process-event.js' +import type { StripeEvent } from './spec.js' +import type { ResourceConfig } from './types.js' +import type { Config } from './index.js' + +// Minimal registry with a customer and subscription entry +const registry: Record = { + customers: { + order: 1, + tableName: 'customers', + supportsCreatedFilter: true, + }, + subscriptions: { + order: 2, + tableName: 'subscriptions', + supportsCreatedFilter: true, + }, + products: { + order: 3, + tableName: 'products', + supportsCreatedFilter: true, + }, +} + +function makeEvent(overrides: Partial & { data?: Partial }): StripeEvent { + return { + id: 'evt_test', + object: 'event', + api_version: '2022-11-15', + created: 1700000000, + livemode: false, + pending_webhooks: 0, + request: { id: null, idempotency_key: null }, + type: 'customer.updated', + data: { + object: { id: 'cus_001', object: 'customer' }, + ...overrides.data, + }, + ...overrides, + } as StripeEvent +} + +describe('fromStripeEvent', () => { + it('returns null when data.object has no object field', () => { + const event = makeEvent({ data: { object: {} } }) + expect(fromStripeEvent(event, registry)).toBeNull() + }) + + it('returns null when object type is not in registry', () => { + const event = makeEvent({ data: { object: { id: 'dp_1', object: 'dispute' } } }) + expect(fromStripeEvent(event, registry)).toBeNull() + }) + + it('returns null when object has no id', () => { + const event = makeEvent({ data: { object: { object: 'customer' } } }) + expect(fromStripeEvent(event, registry)).toBeNull() + }) + + it('returns record and state for a known object type', () => { + const event = makeEvent({ type: 'customer.updated' }) + const result = fromStripeEvent(event, registry) + expect(result).not.toBeNull() + expect(result!.record.type).toBe('record') + expect(result!.record.record.stream).toBe('customers') + expect(result!.state.type).toBe('source_state') + expect((result!.state.source_state as { data: { eventId: string } }).data.eventId).toBe('evt_test') + }) + + it('adds _account_id when accountId provided', () => { + const event = makeEvent({ type: 'customer.updated' }) + const result = fromStripeEvent(event, registry, 'acct_123') + expect(result!.record.record.data._account_id).toBe('acct_123') + }) + + it('does not add _account_id when accountId omitted', () => { + const event = makeEvent({ type: 'customer.updated' }) + const result = fromStripeEvent(event, registry) + expect(result!.record.record.data).not.toHaveProperty('_account_id') + }) +}) + +describe('processStripeEvent', () => { + const catalog = { + streams: [ + { stream: { name: 'customers' }, sync_mode: 'incremental' as const }, + { stream: { name: 'subscriptions' }, sync_mode: 'incremental' as const }, + { stream: { name: 'products' }, sync_mode: 'incremental' as const }, + { stream: { name: 'active_entitlements' }, sync_mode: 'incremental' as const }, + ], + } + const streamNames = new Set(['customers', 'subscriptions', 'products', 'active_entitlements']) + const config: Config = { + api_key: 'sk_test_abc', + api_version: '2022-11-15', + } + + async function collect(gen: AsyncGenerator) { + const msgs: unknown[] = [] + for await (const m of gen) msgs.push(m) + return msgs + } + + it('yields nothing when data.object has no object field', async () => { + const event = makeEvent({ data: { object: {} } }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + expect(msgs).toHaveLength(0) + }) + + it('yields nothing when object type not in registry', async () => { + const event = makeEvent({ data: { object: { id: 'dp_1', object: 'dispute' } } }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + expect(msgs).toHaveLength(0) + }) + + it('yields nothing when stream not in catalog', async () => { + const limitedStreams = new Set(['products']) + const event = makeEvent({ type: 'customer.updated' }) + const msgs = await collect( + processStripeEvent(event, config, catalog, registry, limitedStreams) + ) + expect(msgs).toHaveLength(0) + }) + + it('yields record + state for a normal update event', async () => { + const event = makeEvent({ type: 'customer.updated' }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + expect(msgs).toHaveLength(2) + expect((msgs[0] as { type: string }).type).toBe('record') + expect((msgs[1] as { type: string }).type).toBe('source_state') + }) + + it('yields record with deleted:true for delete events', async () => { + const event = makeEvent({ + type: 'customer.deleted', + data: { object: { id: 'cus_001', object: 'customer', deleted: true } }, + }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + expect(msgs).toHaveLength(2) + const record = msgs[0] as { type: string; record: { data: Record } } + expect(record.record.data.deleted).toBe(true) + }) + + it('detects delete via RESOURCE_DELETE_EVENTS set', async () => { + const event = makeEvent({ + type: 'product.deleted', + data: { object: { id: 'prod_1', object: 'product' } }, + }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + expect(msgs).toHaveLength(2) + const record = msgs[0] as { type: string; record: { data: Record } } + expect(record.record.data.deleted).toBe(true) + }) + + it('yields subscription items when subscription has items.data', async () => { + const event = makeEvent({ + type: 'customer.subscription.updated', + data: { + object: { + id: 'sub_1', + object: 'subscription', + items: { + data: [ + { id: 'si_1', object: 'subscription_item' }, + { id: 'si_2', object: 'subscription_item' }, + ], + }, + }, + }, + }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + // subscription record + 2 subscription_item records + state + expect(msgs.length).toBeGreaterThanOrEqual(3) + const recordTypes = (msgs as { type: string; record?: { stream: string } }[]) + .filter((m) => m.type === 'record') + .map((m) => m.record?.stream) + expect(recordTypes).toContain('subscription_items') + }) + + it('handles entitlements.active_entitlement_summary.updated', async () => { + const event = makeEvent({ + type: 'entitlements.active_entitlement_summary.updated', + data: { + object: { + object: 'entitlements.active_entitlement_summary', + customer: 'cus_001', + entitlements: { + data: [ + { + id: 'ent_1', + object: 'entitlements.active_entitlement', + feature: 'feat_1', + livemode: false, + lookup_key: 'key_1', + }, + ], + }, + }, + }, + }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, streamNames)) + expect(msgs).toHaveLength(2) // 1 record + 1 state + expect((msgs[0] as { type: string }).type).toBe('record') + }) + + it('skips entitlement summary when active_entitlements not in streams', async () => { + const limited = new Set(['customers']) + const event = makeEvent({ + type: 'entitlements.active_entitlement_summary.updated', + data: { + object: { + object: 'entitlements.active_entitlement_summary', + customer: 'cus_001', + entitlements: { data: [] }, + }, + }, + }) + const msgs = await collect(processStripeEvent(event, config, catalog, registry, limited)) + expect(msgs).toHaveLength(0) + }) +}) diff --git a/packages/source-stripe/src/retry.test.ts b/packages/source-stripe/src/retry.test.ts new file mode 100644 index 000000000..802872804 --- /dev/null +++ b/packages/source-stripe/src/retry.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import { getHttpErrorStatus, isRetryableHttpError, withHttpRetry } from './retry.js' + +describe('getHttpErrorStatus', () => { + it('returns undefined for non-objects', () => { + expect(getHttpErrorStatus(null)).toBeUndefined() + expect(getHttpErrorStatus('error')).toBeUndefined() + expect(getHttpErrorStatus(42)).toBeUndefined() + }) + + it('reads .status', () => { + expect(getHttpErrorStatus({ status: 429 })).toBe(429) + }) + + it('reads .statusCode', () => { + expect(getHttpErrorStatus({ statusCode: 500 })).toBe(500) + }) + + it('reads .code when numeric', () => { + expect(getHttpErrorStatus({ code: 503 })).toBe(503) + }) + + it('prefers .status over .statusCode', () => { + expect(getHttpErrorStatus({ status: 429, statusCode: 503 })).toBe(429) + }) + + it('ignores string codes', () => { + expect(getHttpErrorStatus({ code: 'ECONNRESET' })).toBeUndefined() + }) +}) + +describe('isRetryableHttpError', () => { + it('retries on 429', () => { + expect(isRetryableHttpError({ status: 429 })).toBe(true) + }) + + it('retries on 500+', () => { + expect(isRetryableHttpError({ status: 500 })).toBe(true) + expect(isRetryableHttpError({ status: 503 })).toBe(true) + }) + + it('does not retry on 4xx client errors', () => { + expect(isRetryableHttpError({ status: 400 })).toBe(false) + expect(isRetryableHttpError({ status: 401 })).toBe(false) + expect(isRetryableHttpError({ status: 404 })).toBe(false) + }) + + it('retries on TimeoutError', () => { + const err = new Error('timeout') + err.name = 'TimeoutError' + expect(isRetryableHttpError(err)).toBe(true) + }) + + it('does not retry on AbortError', () => { + const err = new Error('aborted') + err.name = 'AbortError' + expect(isRetryableHttpError(err)).toBe(false) + }) + + it('retries on retryable network error codes', () => { + const err = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }) + expect(isRetryableHttpError(err)).toBe(true) + }) + + it('retries on nested cause with network code', () => { + const cause = Object.assign(new Error('inner'), { code: 'ETIMEDOUT' }) + const err = Object.assign(new Error('outer'), { cause }) + expect(isRetryableHttpError(err)).toBe(true) + }) + + it('retries on messages containing "fetch failed"', () => { + expect(isRetryableHttpError(new Error('fetch failed'))).toBe(true) + }) + + it('retries on messages containing "timeout"', () => { + expect(isRetryableHttpError(new Error('request timeout'))).toBe(true) + }) + + it('does not retry on unrelated errors', () => { + expect(isRetryableHttpError(new Error('some random error'))).toBe(false) + }) +}) + +describe('withHttpRetry', () => { + it('returns result on first success', async () => { + const fn = vi.fn().mockResolvedValue('ok') + const result = await withHttpRetry(fn, { baseDelayMs: 0 }) + expect(result).toBe('ok') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('retries on retryable error then succeeds', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ status: 503 }) + .mockResolvedValue('done') + const result = await withHttpRetry(fn, { baseDelayMs: 0 }) + expect(result).toBe('done') + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('throws immediately on non-retryable error', async () => { + const fn = vi.fn().mockRejectedValue({ status: 400 }) + await expect(withHttpRetry(fn, { baseDelayMs: 0 })).rejects.toEqual({ status: 400 }) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('throws after maxRetries exhausted', async () => { + const err = { status: 500 } + const fn = vi.fn().mockRejectedValue(err) + await expect(withHttpRetry(fn, { maxRetries: 2, baseDelayMs: 0 })).rejects.toEqual(err) + expect(fn).toHaveBeenCalledTimes(3) // initial + 2 retries + }) + + it('throws if signal is already aborted', async () => { + const controller = new AbortController() + controller.abort(new Error('aborted')) + const fn = vi.fn().mockResolvedValue('ok') + await expect(withHttpRetry(fn, { signal: controller.signal })).rejects.toThrow() + expect(fn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/source-stripe/src/transforms/backfillDependencies.test.ts b/packages/source-stripe/src/transforms/backfillDependencies.test.ts new file mode 100644 index 000000000..d2ddb880e --- /dev/null +++ b/packages/source-stripe/src/transforms/backfillDependencies.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import { backfillDependencies } from './backfillDependencies.js' +import type { ResourceConfig } from '../types.js' + +const registry: Record = { + invoices: { + order: 1, + tableName: 'invoices', + supportsCreatedFilter: true, + dependencies: ['customers', 'subscriptions'], + }, + charges: { + order: 2, + tableName: 'charges', + supportsCreatedFilter: true, + // no dependencies + }, +} + +describe('backfillDependencies', () => { + it('calls backfillAny for each dependency with unique ids', async () => { + const backfillAny = vi.fn().mockResolvedValue([]) + const items = [ + { customers: 'cus_1', subscriptions: 'sub_1' }, + { customers: 'cus_2', subscriptions: 'sub_1' }, // sub_1 duplicate + { customers: 'cus_1' }, // cus_1 duplicate + ] + + await backfillDependencies({ + items, + syncObjectName: 'invoices', + accountId: 'acct_123', + registry, + backfillAny, + }) + + expect(backfillAny).toHaveBeenCalledTimes(2) + const callsMap = new Map(backfillAny.mock.calls.map((c) => [c[1] as string, c[0] as string[]])) + expect(callsMap.get('customers')?.sort()).toEqual(['cus_1', 'cus_2']) + expect(callsMap.get('subscriptions')).toEqual(['sub_1']) + }) + + it('passes accountId and syncTimestamp through', async () => { + const backfillAny = vi.fn().mockResolvedValue([]) + await backfillDependencies({ + items: [{ customers: 'cus_1' }], + syncObjectName: 'invoices', + accountId: 'acct_abc', + syncTimestamp: '2024-01-01T00:00:00Z', + registry, + backfillAny, + }) + + expect(backfillAny).toHaveBeenCalledWith( + ['cus_1'], + 'customers', + 'acct_abc', + '2024-01-01T00:00:00Z' + ) + }) + + it('does nothing when object has no dependencies', async () => { + const backfillAny = vi.fn() + await backfillDependencies({ + items: [{ customers: 'cus_1' }], + syncObjectName: 'charges', + accountId: 'acct_abc', + registry, + backfillAny, + }) + expect(backfillAny).not.toHaveBeenCalled() + }) + + it('does nothing when object is not in registry', async () => { + const backfillAny = vi.fn() + await backfillDependencies({ + items: [{ customers: 'cus_1' }], + syncObjectName: 'unknown_object', + accountId: 'acct_abc', + registry, + backfillAny, + }) + expect(backfillAny).not.toHaveBeenCalled() + }) + + it('filters out null/undefined dependency values', async () => { + const backfillAny = vi.fn().mockResolvedValue([]) + const items = [ + { customers: 'cus_1', subscriptions: null }, + { customers: undefined, subscriptions: 'sub_1' }, + ] + await backfillDependencies({ + items, + syncObjectName: 'invoices', + accountId: 'acct_abc', + registry, + backfillAny, + }) + + const callsMap = new Map(backfillAny.mock.calls.map((c) => [c[1] as string, c[0] as string[]])) + expect(callsMap.get('customers')).toEqual(['cus_1']) + expect(callsMap.get('subscriptions')).toEqual(['sub_1']) + }) +}) diff --git a/packages/source-stripe/src/transforms/subscriptionItems.test.ts b/packages/source-stripe/src/transforms/subscriptionItems.test.ts new file mode 100644 index 000000000..4400566c4 --- /dev/null +++ b/packages/source-stripe/src/transforms/subscriptionItems.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { syncSubscriptionItems, upsertSubscriptionItems } from './subscriptionItems.js' + +describe('upsertSubscriptionItems', () => { + it('normalizes price from object to string id', async () => { + const upsertMany = vi.fn().mockResolvedValue([]) + await upsertSubscriptionItems( + [{ id: 'si_1', price: { id: 'price_abc' } }], + 'acct_test', + upsertMany + ) + + const [items] = upsertMany.mock.calls[0] as [Record[], string, string] + expect(items[0].price).toBe('price_abc') + }) + + it('keeps price as-is when already a string', async () => { + const upsertMany = vi.fn().mockResolvedValue([]) + await upsertSubscriptionItems( + [{ id: 'si_1', price: 'price_xyz' }], + 'acct_test', + upsertMany + ) + + const [items] = upsertMany.mock.calls[0] as [Record[], string, string] + expect(items[0].price).toBe('price_xyz') + }) + + it('defaults deleted to false when not set', async () => { + const upsertMany = vi.fn().mockResolvedValue([]) + await upsertSubscriptionItems([{ id: 'si_1', price: 'price_1' }], 'acct_test', upsertMany) + + const [items] = upsertMany.mock.calls[0] as [Record[], string, string] + expect(items[0].deleted).toBe(false) + }) + + it('preserves deleted:true', async () => { + const upsertMany = vi.fn().mockResolvedValue([]) + await upsertSubscriptionItems( + [{ id: 'si_1', price: 'price_1', deleted: true }], + 'acct_test', + upsertMany + ) + + const [items] = upsertMany.mock.calls[0] as [Record[], string, string] + expect(items[0].deleted).toBe(true) + }) + + it('defaults quantity to null when not set', async () => { + const upsertMany = vi.fn().mockResolvedValue([]) + await upsertSubscriptionItems([{ id: 'si_1', price: 'price_1' }], 'acct_test', upsertMany) + + const [items] = upsertMany.mock.calls[0] as [Record[], string, string] + expect(items[0].quantity).toBeNull() + }) + + it('passes accountId and syncTimestamp to upsertMany', async () => { + const upsertMany = vi.fn().mockResolvedValue([]) + await upsertSubscriptionItems( + [{ id: 'si_1', price: 'price_1' }], + 'acct_123', + upsertMany, + '2024-01-01T00:00:00Z' + ) + + expect(upsertMany).toHaveBeenCalledWith( + expect.any(Array), + 'subscription_items', + 'acct_123', + '2024-01-01T00:00:00Z' + ) + }) +}) + +describe('syncSubscriptionItems', () => { + it('upserts all items from all subscriptions', async () => { + const upsertItems = vi.fn().mockResolvedValue(undefined) + const markDeleted = vi.fn().mockResolvedValue({ rowCount: 0 }) + + await syncSubscriptionItems({ + subscriptions: [ + { id: 'sub_1', items: { data: [{ id: 'si_1', price: 'price_1' }] } }, + { id: 'sub_2', items: { data: [{ id: 'si_2', price: 'price_2' }] } }, + ], + accountId: 'acct_test', + upsertItems, + markDeleted, + }) + + expect(upsertItems).toHaveBeenCalledOnce() + const [items] = upsertItems.mock.calls[0] as [{ id: string }[], string] + expect(items.map((i) => i.id).sort()).toEqual(['si_1', 'si_2']) + }) + + it('calls markDeleted for each subscription with current item ids', async () => { + const upsertItems = vi.fn().mockResolvedValue(undefined) + const markDeleted = vi.fn().mockResolvedValue({ rowCount: 0 }) + + await syncSubscriptionItems({ + subscriptions: [ + { + id: 'sub_1', + items: { data: [{ id: 'si_1', price: 'price_1' }, { id: 'si_2', price: 'price_1' }] }, + }, + ], + accountId: 'acct_test', + upsertItems, + markDeleted, + }) + + expect(markDeleted).toHaveBeenCalledWith('sub_1', ['si_1', 'si_2']) + }) + + it('skips subscriptions without items.data', async () => { + const upsertItems = vi.fn().mockResolvedValue(undefined) + const markDeleted = vi.fn().mockResolvedValue({ rowCount: 0 }) + + await syncSubscriptionItems({ + subscriptions: [ + { id: 'sub_1', items: { data: [] } }, + { id: 'sub_no_items' } as unknown as Parameters[0]['subscriptions'][0], + ], + accountId: 'acct_test', + upsertItems, + markDeleted, + }) + + // Only sub_1 has items, so markDeleted called once + expect(markDeleted).toHaveBeenCalledTimes(1) + expect(markDeleted).toHaveBeenCalledWith('sub_1', []) + }) +}) diff --git a/packages/source-stripe/src/utils/expandEntity.test.ts b/packages/source-stripe/src/utils/expandEntity.test.ts new file mode 100644 index 000000000..1b09a0e24 --- /dev/null +++ b/packages/source-stripe/src/utils/expandEntity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { expandEntity } from './expandEntity.js' +import type { StripeApiList } from '@stripe/sync-openapi' + +function makeList( + data: T[], + has_more = false +): StripeApiList { + return { object: 'list', data, has_more, url: '/v1/test' } +} + +describe('expandEntity', () => { + it('does nothing when list is already complete (has_more=false)', async () => { + const listFn = vi.fn() + const entities = [ + { id: 'sub_1', items: makeList([{ id: 'si_1' }, { id: 'si_2' }], false) }, + ] + + await expandEntity(entities, 'items', listFn) + + expect(listFn).not.toHaveBeenCalled() + expect(entities[0].items.data).toHaveLength(2) + }) + + it('fetches all pages when has_more=true', async () => { + const listFn = vi + .fn() + .mockResolvedValueOnce(makeList([{ id: 'si_1' }, { id: 'si_2' }], true)) + .mockResolvedValueOnce(makeList([{ id: 'si_3' }], false)) + + const entities = [{ id: 'sub_1', items: makeList([], true) }] + await expandEntity(entities, 'items', listFn) + + expect(listFn).toHaveBeenCalledTimes(2) + expect(listFn).toHaveBeenNthCalledWith(1, 'sub_1', undefined) + expect(listFn).toHaveBeenNthCalledWith(2, 'sub_1', { starting_after: 'si_2' }) + expect(entities[0].items.data).toHaveLength(3) + expect(entities[0].items.has_more).toBe(false) + }) + + it('fetches when property is missing (no existing list)', async () => { + const listFn = vi.fn().mockResolvedValueOnce(makeList([{ id: 'si_1' }], false)) + const entities: { id: string; items?: StripeApiList<{ id: string }> | null }[] = [ + { id: 'sub_1', items: null }, + ] + + await expandEntity(entities, 'items', listFn) + + expect(listFn).toHaveBeenCalledOnce() + expect(entities[0].items!.data).toHaveLength(1) + }) + + it('processes multiple entities independently', async () => { + const listFn = vi + .fn() + .mockResolvedValueOnce(makeList([{ id: 'si_a1' }], false)) + .mockResolvedValueOnce(makeList([{ id: 'si_b1' }, { id: 'si_b2' }], false)) + + const entities = [ + { id: 'sub_1', items: makeList([], true) }, + { id: 'sub_2', items: makeList([], true) }, + ] + + await expandEntity(entities, 'items', listFn) + + expect(listFn).toHaveBeenCalledTimes(2) + expect(entities[0].items.data).toHaveLength(1) + expect(entities[1].items.data).toHaveLength(2) + }) + + it('handles an empty first page (no starting_after set)', async () => { + const listFn = vi.fn().mockResolvedValueOnce(makeList([], false)) + const entities = [{ id: 'sub_1', items: makeList([], true) }] + + await expandEntity(entities, 'items', listFn) + + expect(listFn).toHaveBeenCalledTimes(1) + expect(entities[0].items.data).toHaveLength(0) + }) +}) diff --git a/packages/source-stripe/src/utils/hashApiKey.test.ts b/packages/source-stripe/src/utils/hashApiKey.test.ts new file mode 100644 index 000000000..16c8ef5aa --- /dev/null +++ b/packages/source-stripe/src/utils/hashApiKey.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { hashApiKey } from './hashApiKey.js' + +describe('hashApiKey', () => { + it('returns a 64-character hex string (SHA-256)', () => { + const hash = hashApiKey('sk_test_abc123') + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it('is deterministic for the same input', () => { + expect(hashApiKey('sk_test_abc')).toBe(hashApiKey('sk_test_abc')) + }) + + it('produces different hashes for different keys', () => { + expect(hashApiKey('sk_test_aaa')).not.toBe(hashApiKey('sk_test_bbb')) + }) + + it('produces the expected SHA-256 hash', () => { + // echo -n "sk_test_known" | sha256sum + const expected = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + // verify the empty string hash matches node:crypto output + expect(hashApiKey('')).toBe(expected) + }) +}) diff --git a/scripts/pull-and-start-engine.sh b/scripts/pull-and-start-engine.sh new file mode 100755 index 000000000..894eec8dd --- /dev/null +++ b/scripts/pull-and-start-engine.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Pull and start the sync-engine Docker image from Docker Hub on port 4020. +# Usage: ./scripts/pull-and-start-engine.sh [--pull-only | --no-pull | --pretty] + +set -euo pipefail + +case "${1:-}" in + --pull-only) + echo "Pulling stripe/sync-engine:v2…" + docker pull stripe/sync-engine:v2 + ;; + + --no-pull) + echo "Removing existing sync-engine container…" + docker rm -f sync-engine 2>/dev/null || true + echo "Starting sync-engine on port 4020…" + docker run --rm -i \ + --name sync-engine \ + --network host \ + -e PORT=4020 \ + --env-file <(env | grep -E '^(STRIPE_|DATABASE_|STATE_|PG_|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY)' 2>/dev/null || true) \ + stripe/sync-engine:v2 + ;; + + --pretty) + echo "Pulling stripe/sync-engine:v2…" + docker pull stripe/sync-engine:v2 + echo "Removing existing sync-engine container…" + docker rm -f sync-engine 2>/dev/null || true + echo "Starting sync-engine on port 4020…" + docker run --rm -i \ + --name sync-engine \ + --network host \ + -e PORT=4020 \ + --env-file <(env | grep -E '^(STRIPE_|DATABASE_|STATE_|PG_|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY)' 2>/dev/null || true) \ + stripe/sync-engine:v2 \ + 2>&1 | node_modules/.pnpm/node_modules/.bin/pino-pretty + ;; + + *) + echo "Pulling stripe/sync-engine:v2…" + docker pull stripe/sync-engine:v2 + echo "Removing existing sync-engine container…" + docker rm -f sync-engine 2>/dev/null || true + echo "Starting sync-engine on port 4020…" + docker run --rm -i \ + --name sync-engine \ + --network host \ + -e PORT=4020 \ + --env-file <(env | grep -E '^(STRIPE_|DATABASE_|STATE_|PG_|http_proxy|https_proxy|HTTP_PROXY|HTTPS_PROXY)' 2>/dev/null || true) \ + stripe/sync-engine:v2 + ;; +esac