payment-kit 1.27.2 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +32 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +70 -24
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +29 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +39 -21
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +131 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +37 -1
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +66 -1
- package/api/src/store/models/price.ts +23 -14
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +391 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +611 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1608 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +75 -0
- package/cloudflare/wrangler.staging.json +67 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +30 -22
- package/scripts/seed-google-play.ts +79 -0
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/safe-did-address.tsx +75 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +8 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Unit tests for Google Play RTDN dispatch + subscription state machine.
|
|
2
|
+
// Models / audit / client are fully mocked — DB is not touched.
|
|
3
|
+
|
|
4
|
+
import handleGooglePlayEvent, { GooglePlayRtdnPayload } from '../../../src/integrations/google-play/handlers';
|
|
5
|
+
import {
|
|
6
|
+
GooglePlayNotificationType,
|
|
7
|
+
handleGooglePlaySubscriptionEvent,
|
|
8
|
+
} from '../../../src/integrations/google-play/handlers/subscription';
|
|
9
|
+
import type { GooglePlayClient } from '../../../src/integrations/google-play/client';
|
|
10
|
+
|
|
11
|
+
const mockSubscriptionFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
12
|
+
const mockCustomerFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
13
|
+
const mockRefundFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
14
|
+
// returns a Promise so handler's .catch() works
|
|
15
|
+
const mockCreateEvent: jest.Mock<Promise<undefined>, any[]> = jest.fn(async () => undefined);
|
|
16
|
+
|
|
17
|
+
jest.mock('../../../src/store/models', () => ({
|
|
18
|
+
Subscription: { findOne: (...args: any[]) => mockSubscriptionFindOne(...args) },
|
|
19
|
+
Customer: { findOne: (...args: any[]) => mockCustomerFindOne(...args) },
|
|
20
|
+
Refund: { findOne: (...args: any[]) => mockRefundFindOne(...args) },
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock('../../../src/libs/audit', () => ({
|
|
24
|
+
createEvent: (...args: any[]) => mockCreateEvent(...args),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const makeSubscriptionStub = (overrides: Record<string, any> = {}) => {
|
|
28
|
+
const update = jest.fn(async (patch: any) => Object.assign(stub, patch));
|
|
29
|
+
const stub: any = {
|
|
30
|
+
id: 'sub_test',
|
|
31
|
+
status: 'active',
|
|
32
|
+
current_period_end: 1700000000,
|
|
33
|
+
payment_details: { google_play: { purchase_token: 'tok-1', product_id: 'pro_monthly' } },
|
|
34
|
+
metadata: {},
|
|
35
|
+
update,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
return stub;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const makeClientMock = (overrides: Partial<GooglePlayClient> = {}): GooglePlayClient => {
|
|
42
|
+
return {
|
|
43
|
+
packageName: 'com.example.app',
|
|
44
|
+
getSubscription: jest.fn(),
|
|
45
|
+
acknowledgeSubscription: jest.fn(),
|
|
46
|
+
...overrides,
|
|
47
|
+
} as unknown as GooglePlayClient;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mockSubscriptionFindOne.mockReset();
|
|
52
|
+
mockCustomerFindOne.mockReset();
|
|
53
|
+
mockRefundFindOne.mockReset();
|
|
54
|
+
mockCreateEvent.mockReset();
|
|
55
|
+
mockCreateEvent.mockImplementation(async () => undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('handleGooglePlayEvent dispatch', () => {
|
|
59
|
+
it('returns silently for testNotification (no work)', async () => {
|
|
60
|
+
const client = makeClientMock();
|
|
61
|
+
const payload: GooglePlayRtdnPayload = {
|
|
62
|
+
version: '1.0',
|
|
63
|
+
packageName: 'com.example.app',
|
|
64
|
+
testNotification: { version: '1.0' },
|
|
65
|
+
};
|
|
66
|
+
await expect(handleGooglePlayEvent(payload, client)).resolves.toBeUndefined();
|
|
67
|
+
expect(mockSubscriptionFindOne).not.toHaveBeenCalled();
|
|
68
|
+
expect(client.acknowledgeSubscription).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('forwards subscriptionNotification to the subscription handler', async () => {
|
|
72
|
+
mockSubscriptionFindOne.mockResolvedValue(makeSubscriptionStub());
|
|
73
|
+
const client = makeClientMock();
|
|
74
|
+
const payload: GooglePlayRtdnPayload = {
|
|
75
|
+
version: '1.0',
|
|
76
|
+
packageName: 'com.example.app',
|
|
77
|
+
subscriptionNotification: {
|
|
78
|
+
version: '1.0',
|
|
79
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_CANCELED,
|
|
80
|
+
purchaseToken: 'tok-1',
|
|
81
|
+
subscriptionId: 'pro_monthly',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
await handleGooglePlayEvent(payload, client);
|
|
85
|
+
expect(mockSubscriptionFindOne).toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('logs but does not throw for oneTimeProductNotification (not handled in A2)', async () => {
|
|
89
|
+
const client = makeClientMock();
|
|
90
|
+
const payload: GooglePlayRtdnPayload = {
|
|
91
|
+
version: '1.0',
|
|
92
|
+
packageName: 'com.example.app',
|
|
93
|
+
oneTimeProductNotification: { version: '1.0', notificationType: 1, purchaseToken: 'tok', sku: 'one_off' },
|
|
94
|
+
};
|
|
95
|
+
await expect(handleGooglePlayEvent(payload, client)).resolves.toBeUndefined();
|
|
96
|
+
expect(mockSubscriptionFindOne).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('SUBSCRIPTION_PURCHASED', () => {
|
|
101
|
+
it('acknowledges Google before any further work, even on orphan purchase', async () => {
|
|
102
|
+
const ack = jest.fn();
|
|
103
|
+
const client = makeClientMock({
|
|
104
|
+
acknowledgeSubscription: ack as any,
|
|
105
|
+
getSubscription: (jest.fn().mockResolvedValue({
|
|
106
|
+
obfuscatedExternalAccountId: 'uuid-not-mapped',
|
|
107
|
+
}) as any),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
mockCustomerFindOne.mockResolvedValue(null); // orphan: no customer
|
|
111
|
+
|
|
112
|
+
await handleGooglePlaySubscriptionEvent({
|
|
113
|
+
packageName: 'com.example.app',
|
|
114
|
+
client,
|
|
115
|
+
notification: {
|
|
116
|
+
version: '1.0',
|
|
117
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_PURCHASED,
|
|
118
|
+
purchaseToken: 'tok-fresh',
|
|
119
|
+
subscriptionId: 'pro_monthly',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ACK was called — this is the 3-day-deadline guarantee
|
|
124
|
+
expect(ack).toHaveBeenCalledWith('pro_monthly', 'tok-fresh');
|
|
125
|
+
// Customer lookup happened but returned null — handler must not crash
|
|
126
|
+
expect(mockCustomerFindOne).toHaveBeenCalledWith({ where: { google_play_uuid: 'uuid-not-mapped' } });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('throws when acknowledge fails (so caller can retry)', async () => {
|
|
130
|
+
const ack = jest.fn().mockRejectedValue(new Error('network'));
|
|
131
|
+
const client = makeClientMock({ acknowledgeSubscription: ack as any });
|
|
132
|
+
await expect(
|
|
133
|
+
handleGooglePlaySubscriptionEvent({
|
|
134
|
+
packageName: 'com.example.app',
|
|
135
|
+
client,
|
|
136
|
+
notification: {
|
|
137
|
+
version: '1.0',
|
|
138
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_PURCHASED,
|
|
139
|
+
purchaseToken: 'tok-fresh',
|
|
140
|
+
subscriptionId: 'pro_monthly',
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
).rejects.toThrow(/network/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('SUBSCRIPTION_RENEWED', () => {
|
|
148
|
+
it('updates current_period_end + emits customer.subscription.started', async () => {
|
|
149
|
+
const sub = makeSubscriptionStub();
|
|
150
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
151
|
+
const client = makeClientMock({
|
|
152
|
+
getSubscription: jest.fn().mockResolvedValue({ expiryTimeMillis: '1800000000000' }) as any,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await handleGooglePlaySubscriptionEvent({
|
|
156
|
+
packageName: 'com.example.app',
|
|
157
|
+
client,
|
|
158
|
+
notification: {
|
|
159
|
+
version: '1.0',
|
|
160
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_RENEWED,
|
|
161
|
+
purchaseToken: 'tok-1',
|
|
162
|
+
subscriptionId: 'pro_monthly',
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
167
|
+
expect.objectContaining({ status: 'active', current_period_end: 1800000000 })
|
|
168
|
+
);
|
|
169
|
+
expect(mockCreateEvent).toHaveBeenCalledWith(
|
|
170
|
+
'Subscription',
|
|
171
|
+
'customer.subscription.started',
|
|
172
|
+
sub
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('SUBSCRIPTION_EXPIRED', () => {
|
|
178
|
+
it('marks subscription canceled + emits customer.subscription.deleted', async () => {
|
|
179
|
+
const sub = makeSubscriptionStub();
|
|
180
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
181
|
+
const client = makeClientMock();
|
|
182
|
+
|
|
183
|
+
await handleGooglePlaySubscriptionEvent({
|
|
184
|
+
packageName: 'com.example.app',
|
|
185
|
+
client,
|
|
186
|
+
notification: {
|
|
187
|
+
version: '1.0',
|
|
188
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_EXPIRED,
|
|
189
|
+
purchaseToken: 'tok-1',
|
|
190
|
+
subscriptionId: 'pro_monthly',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({ status: 'canceled' })
|
|
196
|
+
);
|
|
197
|
+
expect(mockCreateEvent).toHaveBeenCalledWith(
|
|
198
|
+
'Subscription',
|
|
199
|
+
'customer.subscription.deleted',
|
|
200
|
+
sub
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('is idempotent (no update if already canceled)', async () => {
|
|
205
|
+
const sub = makeSubscriptionStub({ status: 'canceled' });
|
|
206
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
207
|
+
const client = makeClientMock();
|
|
208
|
+
|
|
209
|
+
await handleGooglePlaySubscriptionEvent({
|
|
210
|
+
packageName: 'com.example.app',
|
|
211
|
+
client,
|
|
212
|
+
notification: {
|
|
213
|
+
version: '1.0',
|
|
214
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_EXPIRED,
|
|
215
|
+
purchaseToken: 'tok-1',
|
|
216
|
+
subscriptionId: 'pro_monthly',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('SUBSCRIPTION_CANCELED', () => {
|
|
225
|
+
it('schedules cancel_at_period_end without immediately ending the subscription', async () => {
|
|
226
|
+
const sub = makeSubscriptionStub();
|
|
227
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
228
|
+
const client = makeClientMock();
|
|
229
|
+
|
|
230
|
+
await handleGooglePlaySubscriptionEvent({
|
|
231
|
+
packageName: 'com.example.app',
|
|
232
|
+
client,
|
|
233
|
+
notification: {
|
|
234
|
+
version: '1.0',
|
|
235
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_CANCELED,
|
|
236
|
+
purchaseToken: 'tok-1',
|
|
237
|
+
subscriptionId: 'pro_monthly',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(sub.update).toHaveBeenCalledWith({ cancel_at_period_end: true });
|
|
242
|
+
expect(mockCreateEvent).not.toHaveBeenCalledWith(
|
|
243
|
+
'Subscription',
|
|
244
|
+
'customer.subscription.deleted',
|
|
245
|
+
expect.anything()
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('SUBSCRIPTION_ON_HOLD / IN_GRACE_PERIOD', () => {
|
|
251
|
+
it.each([
|
|
252
|
+
GooglePlayNotificationType.SUBSCRIPTION_ON_HOLD,
|
|
253
|
+
GooglePlayNotificationType.SUBSCRIPTION_IN_GRACE_PERIOD,
|
|
254
|
+
])('marks past_due (type=%i)', async (notificationType) => {
|
|
255
|
+
const sub = makeSubscriptionStub();
|
|
256
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
257
|
+
const client = makeClientMock();
|
|
258
|
+
|
|
259
|
+
await handleGooglePlaySubscriptionEvent({
|
|
260
|
+
packageName: 'com.example.app',
|
|
261
|
+
client,
|
|
262
|
+
notification: { version: '1.0', notificationType, purchaseToken: 'tok-1', subscriptionId: 'pro_monthly' },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(sub.update).toHaveBeenCalledWith({ status: 'past_due' });
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('SUBSCRIPTION_REVOKED', () => {
|
|
270
|
+
it('marks canceled + emits customer.subscription.deleted (no local Refund row)', async () => {
|
|
271
|
+
const sub = makeSubscriptionStub();
|
|
272
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
273
|
+
|
|
274
|
+
await handleGooglePlaySubscriptionEvent({
|
|
275
|
+
packageName: 'com.example.app',
|
|
276
|
+
client: makeClientMock(),
|
|
277
|
+
notification: {
|
|
278
|
+
version: '1.0',
|
|
279
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_REVOKED,
|
|
280
|
+
purchaseToken: 'tok-1',
|
|
281
|
+
subscriptionId: 'pro_monthly',
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
286
|
+
expect.objectContaining({ status: 'canceled' })
|
|
287
|
+
);
|
|
288
|
+
// No local Refund row is created — the store is merchant of record.
|
|
289
|
+
expect(mockRefundFindOne).not.toHaveBeenCalled();
|
|
290
|
+
expect(mockCreateEvent).toHaveBeenCalledWith(
|
|
291
|
+
'Subscription',
|
|
292
|
+
'customer.subscription.deleted',
|
|
293
|
+
sub
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('handler is no-op for unmatched purchase token (notifications other than PURCHASED)', () => {
|
|
299
|
+
it('logs a warning and returns', async () => {
|
|
300
|
+
mockSubscriptionFindOne.mockResolvedValue(null);
|
|
301
|
+
const client = makeClientMock();
|
|
302
|
+
|
|
303
|
+
await handleGooglePlaySubscriptionEvent({
|
|
304
|
+
packageName: 'com.example.app',
|
|
305
|
+
client,
|
|
306
|
+
notification: {
|
|
307
|
+
version: '1.0',
|
|
308
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_RENEWED,
|
|
309
|
+
purchaseToken: 'tok-missing',
|
|
310
|
+
subscriptionId: 'pro_monthly',
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(client.getSubscription).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('SUBSCRIPTION_REVOKED refund marker', () => {
|
|
319
|
+
it('mirrors to canceled and sets metadata.refunded', async () => {
|
|
320
|
+
const sub = makeSubscriptionStub();
|
|
321
|
+
mockSubscriptionFindOne.mockResolvedValue(sub);
|
|
322
|
+
|
|
323
|
+
await handleGooglePlaySubscriptionEvent({
|
|
324
|
+
packageName: 'com.example.app',
|
|
325
|
+
client: makeClientMock(),
|
|
326
|
+
notification: {
|
|
327
|
+
version: '1.0',
|
|
328
|
+
notificationType: GooglePlayNotificationType.SUBSCRIPTION_REVOKED,
|
|
329
|
+
purchaseToken: 'tok-1',
|
|
330
|
+
subscriptionId: 'pro_monthly',
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(sub.update).toHaveBeenCalledWith(
|
|
335
|
+
expect.objectContaining({
|
|
336
|
+
status: 'canceled',
|
|
337
|
+
metadata: expect.objectContaining({ refunded: true }),
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__resetJwksCacheForTests,
|
|
3
|
+
decodePubSubJwt,
|
|
4
|
+
decodePubSubMessage,
|
|
5
|
+
verifyPubSubJwt,
|
|
6
|
+
} from '../../../src/integrations/google-play/verify';
|
|
7
|
+
|
|
8
|
+
const buildJwt = (claims: Record<string, unknown>, header: Record<string, unknown> = { alg: 'RS256', typ: 'JWT' }): string => {
|
|
9
|
+
const headerSeg = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
10
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
11
|
+
return `${headerSeg}.${payload}.signature-mock`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const AUDIENCE = 'https://example.com/api/integrations/google-play/webhook';
|
|
15
|
+
const NOW = 1700000000;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
__resetJwksCacheForTests();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('decodePubSubJwt', () => {
|
|
22
|
+
it('decodes claims from the payload segment', () => {
|
|
23
|
+
const token = buildJwt({ iss: 'https://accounts.google.com', aud: AUDIENCE, iat: NOW, exp: NOW + 600 });
|
|
24
|
+
const claims = decodePubSubJwt(token);
|
|
25
|
+
expect(claims.iss).toBe('https://accounts.google.com');
|
|
26
|
+
expect(claims.aud).toBe(AUDIENCE);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws on malformed JWT', () => {
|
|
30
|
+
expect(() => decodePubSubJwt('not.a.jwt.extra')).toThrow(/3 segments/);
|
|
31
|
+
expect(() => decodePubSubJwt('only-one-segment')).toThrow(/3 segments/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Claims-level tests use skipSignature so we can synthesize JWTs without RSA
|
|
36
|
+
// key material. These prove the claim validation logic is correct; the
|
|
37
|
+
// signature path is covered separately below.
|
|
38
|
+
describe('verifyPubSubJwt — claim validation (signature skipped)', () => {
|
|
39
|
+
const validClaims = { iss: 'https://accounts.google.com', aud: AUDIENCE, iat: NOW, exp: NOW + 600 };
|
|
40
|
+
const opts = { expectedAudience: AUDIENCE, now: NOW, skipSignature: true };
|
|
41
|
+
|
|
42
|
+
it('passes for well-formed Google-issued JWT with matching audience', async () => {
|
|
43
|
+
const token = buildJwt(validClaims);
|
|
44
|
+
const claims = await verifyPubSubJwt(token, opts);
|
|
45
|
+
expect(claims.aud).toBe(AUDIENCE);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('also accepts `accounts.google.com` issuer (no https prefix)', async () => {
|
|
49
|
+
const token = buildJwt({ ...validClaims, iss: 'accounts.google.com' });
|
|
50
|
+
await expect(verifyPubSubJwt(token, opts)).resolves.toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects an unknown issuer', async () => {
|
|
54
|
+
const token = buildJwt({ ...validClaims, iss: 'https://evil.example.com' });
|
|
55
|
+
await expect(verifyPubSubJwt(token, opts)).rejects.toThrow(/issuer not recognized/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects audience mismatch', async () => {
|
|
59
|
+
const token = buildJwt({ ...validClaims, aud: 'https://other.example.com/webhook' });
|
|
60
|
+
await expect(verifyPubSubJwt(token, opts)).rejects.toThrow(/audience mismatch/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects expired tokens beyond clock tolerance', async () => {
|
|
64
|
+
const token = buildJwt({ ...validClaims, exp: NOW - 200 });
|
|
65
|
+
await expect(
|
|
66
|
+
verifyPubSubJwt(token, { ...opts, clockTolerance: 60 })
|
|
67
|
+
).rejects.toThrow(/expired/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects tokens issued in the future beyond clock tolerance', async () => {
|
|
71
|
+
const token = buildJwt({ ...validClaims, iat: NOW + 200 });
|
|
72
|
+
await expect(
|
|
73
|
+
verifyPubSubJwt(token, { ...opts, clockTolerance: 60 })
|
|
74
|
+
).rejects.toThrow(/future/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts tokens slightly outside exp/iat within clock tolerance', async () => {
|
|
78
|
+
const tokenSlightlyExpired = buildJwt({ ...validClaims, exp: NOW - 30 });
|
|
79
|
+
await expect(
|
|
80
|
+
verifyPubSubJwt(tokenSlightlyExpired, { ...opts, clockTolerance: 60 })
|
|
81
|
+
).resolves.toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Sender-identity binding (PR #1381 review P1): when expectedEmail is set, the
|
|
85
|
+
// token must carry email_verified=true and a matching email.
|
|
86
|
+
const SA = 'pubsub-push@my-project.iam.gserviceaccount.com';
|
|
87
|
+
|
|
88
|
+
it('accepts when expectedEmail matches and email_verified=true', async () => {
|
|
89
|
+
const token = buildJwt({ ...validClaims, email: SA, email_verified: true });
|
|
90
|
+
await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).resolves.toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects when the email does not match the configured service account', async () => {
|
|
94
|
+
const token = buildJwt({ ...validClaims, email: 'attacker@evil.example.com', email_verified: true });
|
|
95
|
+
await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).rejects.toThrow(/email mismatch/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects when email_verified is not true', async () => {
|
|
99
|
+
const token = buildJwt({ ...validClaims, email: SA, email_verified: false });
|
|
100
|
+
await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).rejects.toThrow(/email not verified/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects when the email claim is missing entirely', async () => {
|
|
104
|
+
const token = buildJwt({ ...validClaims });
|
|
105
|
+
await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).rejects.toThrow(/email not verified/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not enforce email when expectedEmail is unset (back-compat)', async () => {
|
|
109
|
+
const token = buildJwt({ ...validClaims });
|
|
110
|
+
await expect(verifyPubSubJwt(token, opts)).resolves.toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Signature-level tests use a freshly generated RSA keypair so we can produce
|
|
115
|
+
// real RS256 JWTs and assert the verifier's signature check actually runs.
|
|
116
|
+
describe('verifyPubSubJwt — RS256 signature verification', () => {
|
|
117
|
+
const validClaims = { iss: 'https://accounts.google.com', aud: AUDIENCE, iat: NOW, exp: NOW + 600 };
|
|
118
|
+
const KID = 'test-kid-2026';
|
|
119
|
+
|
|
120
|
+
let privateKey: CryptoKey;
|
|
121
|
+
let publicKey: CryptoKey;
|
|
122
|
+
let fetchJwks: () => Promise<Map<string, CryptoKey>>;
|
|
123
|
+
|
|
124
|
+
beforeAll(async () => {
|
|
125
|
+
const pair = await globalThis.crypto.subtle.generateKey(
|
|
126
|
+
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
127
|
+
true,
|
|
128
|
+
['sign', 'verify']
|
|
129
|
+
);
|
|
130
|
+
privateKey = pair.privateKey;
|
|
131
|
+
publicKey = pair.publicKey;
|
|
132
|
+
const keyMap = new Map<string, CryptoKey>([[KID, publicKey]]);
|
|
133
|
+
fetchJwks = async () => keyMap;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const signJwt = async (claims: Record<string, unknown>, header: Record<string, unknown> = { alg: 'RS256', kid: KID, typ: 'JWT' }): Promise<string> => {
|
|
137
|
+
const headerSeg = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
138
|
+
const payloadSeg = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
139
|
+
const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
|
|
140
|
+
const sig = await globalThis.crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, privateKey, signingInput);
|
|
141
|
+
const sigSeg = Buffer.from(new Uint8Array(sig)).toString('base64url');
|
|
142
|
+
return `${headerSeg}.${payloadSeg}.${sigSeg}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
it('accepts a JWT signed with the matching JWKS key', async () => {
|
|
146
|
+
const token = await signJwt(validClaims);
|
|
147
|
+
const claims = await verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks });
|
|
148
|
+
expect(claims.aud).toBe(AUDIENCE);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('rejects a JWT whose signature has been tampered with', async () => {
|
|
152
|
+
const token = await signJwt(validClaims);
|
|
153
|
+
const tampered = token.slice(0, -4) + 'AAAA';
|
|
154
|
+
await expect(
|
|
155
|
+
verifyPubSubJwt(tampered, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
|
|
156
|
+
).rejects.toThrow(/signature verification failed/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects a JWT signed by a different key (kid not in JWKS)', async () => {
|
|
160
|
+
const token = await signJwt(validClaims, { alg: 'RS256', kid: 'unknown-kid', typ: 'JWT' });
|
|
161
|
+
await expect(
|
|
162
|
+
verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
|
|
163
|
+
).rejects.toThrow(/kid not found/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects JWTs that declare a non-RS256 alg', async () => {
|
|
167
|
+
const headerSeg = Buffer.from(JSON.stringify({ alg: 'HS256', kid: KID })).toString('base64url');
|
|
168
|
+
const payloadSeg = Buffer.from(JSON.stringify(validClaims)).toString('base64url');
|
|
169
|
+
const token = `${headerSeg}.${payloadSeg}.unused-sig`;
|
|
170
|
+
await expect(
|
|
171
|
+
verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
|
|
172
|
+
).rejects.toThrow(/alg not supported/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rejects JWTs missing kid in header', async () => {
|
|
176
|
+
const headerSeg = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
177
|
+
const payloadSeg = Buffer.from(JSON.stringify(validClaims)).toString('base64url');
|
|
178
|
+
const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
|
|
179
|
+
const sig = await globalThis.crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, privateKey, signingInput);
|
|
180
|
+
const sigSeg = Buffer.from(new Uint8Array(sig)).toString('base64url');
|
|
181
|
+
const token = `${headerSeg}.${payloadSeg}.${sigSeg}`;
|
|
182
|
+
await expect(
|
|
183
|
+
verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
|
|
184
|
+
).rejects.toThrow(/missing `kid`/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('still applies claim checks even with valid signature (audience mismatch)', async () => {
|
|
188
|
+
const token = await signJwt({ ...validClaims, aud: 'https://other/webhook' });
|
|
189
|
+
await expect(
|
|
190
|
+
verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
|
|
191
|
+
).rejects.toThrow(/audience mismatch/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('decodePubSubMessage', () => {
|
|
196
|
+
it('decodes base64 data into JSON', () => {
|
|
197
|
+
const inner = { version: '1.0', packageName: 'com.example' };
|
|
198
|
+
const envelope = {
|
|
199
|
+
message: {
|
|
200
|
+
data: Buffer.from(JSON.stringify(inner)).toString('base64'),
|
|
201
|
+
messageId: 'msg-1',
|
|
202
|
+
publishTime: '2024-01-01T00:00:00Z',
|
|
203
|
+
},
|
|
204
|
+
subscription: 'projects/p/subscriptions/s',
|
|
205
|
+
};
|
|
206
|
+
const decoded = decodePubSubMessage<typeof inner>(envelope);
|
|
207
|
+
expect(decoded).toEqual(inner);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('throws when message.data is missing', () => {
|
|
211
|
+
expect(() => decodePubSubMessage({ message: { data: '', messageId: '', publishTime: '' }, subscription: '' })).toThrow(
|
|
212
|
+
/no message.data/
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|