payment-kit 1.25.8 → 1.26.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/api/src/crons/index.ts +24 -0
- package/api/src/libs/archive/config.ts +254 -0
- package/api/src/libs/archive/executor.ts +729 -0
- package/api/src/libs/archive/index.ts +7 -0
- package/api/src/libs/archive/lock.ts +50 -0
- package/api/src/libs/archive/policy.ts +55 -0
- package/api/src/libs/archive/query.ts +136 -0
- package/api/src/libs/archive/snapshot.ts +291 -0
- package/api/src/libs/archive/store.ts +200 -0
- package/api/src/libs/session.ts +43 -25
- package/api/src/queues/archive.ts +32 -0
- package/api/src/queues/subscription.ts +3 -1
- package/api/src/routes/archive.ts +176 -0
- package/api/src/routes/checkout-sessions.ts +50 -34
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/meters.ts +28 -0
- package/api/src/routes/payment-stats.ts +167 -20
- package/api/src/store/migrations/20260203-archive.ts +12 -0
- package/api/src/store/migrations/20260204-revenue-snapshot.ts +19 -0
- package/api/src/store/models/archive-lock.ts +55 -0
- package/api/src/store/models/archive-metadata.ts +132 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/revenue-snapshot.ts +110 -0
- package/api/tests/libs/archive-config.spec.ts +185 -0
- package/api/tests/libs/archive-executor.spec.ts +678 -0
- package/api/tests/libs/archive-lock.spec.ts +130 -0
- package/api/tests/libs/archive-policy.spec.ts +255 -0
- package/api/tests/libs/archive-query.spec.ts +267 -0
- package/api/tests/libs/archive-store.spec.ts +159 -0
- package/blocklet.prefs.json +187 -0
- package/blocklet.yml +2 -1
- package/package.json +10 -10
- package/src/components/customer/actions.tsx +1 -1
- package/src/components/customer/credit-overview.tsx +3 -1
- package/src/components/customer/overdraft-protection.tsx +1 -1
- package/src/components/event/list.tsx +1 -1
- package/src/components/filter-toolbar.tsx +2 -2
- package/src/components/invoice/action.tsx +3 -3
- package/src/components/invoice/list.tsx +1 -1
- package/src/components/invoice/recharge.tsx +2 -2
- package/src/components/meter/add-usage-dialog.tsx +1 -1
- package/src/components/passport/actions.tsx +1 -1
- package/src/components/passport/assign.tsx +1 -1
- package/src/components/payment-currency/add.tsx +1 -1
- package/src/components/payment-currency/edit.tsx +1 -1
- package/src/components/payment-intent/actions.tsx +4 -4
- package/src/components/payment-intent/list.tsx +1 -1
- package/src/components/payment-link/actions.tsx +4 -4
- package/src/components/payment-link/item.tsx +1 -1
- package/src/components/payouts/list.tsx +1 -1
- package/src/components/payouts/portal/list.tsx +1 -1
- package/src/components/price/upsell-select.tsx +1 -1
- package/src/components/price/upsell.tsx +2 -2
- package/src/components/pricing-table/actions.tsx +3 -3
- package/src/components/pricing-table/product-item.tsx +1 -1
- package/src/components/product/actions.tsx +3 -3
- package/src/components/product/create.tsx +1 -1
- package/src/components/product/cross-sell.tsx +2 -2
- package/src/components/promotion/active-redemptions.tsx +1 -1
- package/src/components/refund/list.tsx +1 -1
- package/src/components/subscription/actions/index.tsx +1 -1
- package/src/components/subscription/items/usage-records.tsx +4 -2
- package/src/components/subscription/list.tsx +1 -1
- package/src/components/subscription/metrics.tsx +3 -3
- package/src/components/subscription/portal/actions.tsx +15 -12
- package/src/components/subscription/portal/list.tsx +1 -1
- package/src/components/webhook/attempts.tsx +4 -4
- package/src/hooks/subscription.ts +2 -2
- package/src/locales/en.tsx +4 -0
- package/src/locales/zh.tsx +4 -0
- package/src/pages/admin/billing/meter-events/index.tsx +3 -3
- package/src/pages/admin/billing/meters/index.tsx +1 -1
- package/src/pages/admin/billing/overdue/index.tsx +2 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -2
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +1 -1
- package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +1 -1
- package/src/pages/admin/customers/customers/detail.tsx +4 -4
- package/src/pages/admin/developers/events/detail.tsx +1 -1
- package/src/pages/admin/developers/webhooks/detail.tsx +1 -1
- package/src/pages/admin/developers/webhooks/index.tsx +1 -1
- package/src/pages/admin/overview.tsx +2 -0
- package/src/pages/admin/payments/intents/detail.tsx +2 -2
- package/src/pages/admin/payments/payouts/detail.tsx +2 -2
- package/src/pages/admin/payments/refunds/detail.tsx +2 -2
- package/src/pages/admin/products/coupons/detail.tsx +1 -1
- package/src/pages/admin/products/coupons/index.tsx +1 -1
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +1 -1
- package/src/pages/admin/products/links/create.tsx +1 -1
- package/src/pages/admin/products/links/detail.tsx +2 -2
- package/src/pages/admin/products/links/index.tsx +1 -1
- package/src/pages/admin/products/passports/index.tsx +1 -1
- package/src/pages/admin/products/prices/actions.tsx +4 -4
- package/src/pages/admin/products/prices/detail.tsx +2 -2
- package/src/pages/admin/products/pricing-tables/create.tsx +1 -1
- package/src/pages/admin/products/pricing-tables/detail.tsx +2 -2
- package/src/pages/admin/products/pricing-tables/index.tsx +1 -1
- package/src/pages/admin/products/products/index.tsx +1 -1
- package/src/pages/admin/products/promotion-codes/actions.tsx +2 -2
- package/src/pages/admin/products/promotion-codes/detail.tsx +2 -2
- package/src/pages/admin/products/promotion-codes/list.tsx +1 -1
- package/src/pages/admin/settings/payment-methods/create.tsx +1 -1
- package/src/pages/admin/settings/payment-methods/edit.tsx +1 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +2 -2
- package/src/pages/admin/tax/detail.tsx +2 -2
- package/src/pages/admin/tax/list.tsx +1 -1
- package/src/pages/checkout/pay.tsx +2 -2
- package/src/pages/customer/index.tsx +1 -1
- package/src/pages/customer/invoice/past-due.tsx +1 -1
- package/src/pages/customer/payout/detail.tsx +1 -1
- package/src/pages/customer/refund/list.tsx +1 -1
- package/src/pages/customer/subscription/change-payment.tsx +2 -2
- package/src/pages/customer/subscription/change-plan.tsx +3 -3
- package/src/pages/customer/subscription/detail.tsx +3 -3
- package/src/pages/integrations/donations/index.tsx +1 -1
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { recoverFromCrash, previewArchive, runArchiveJob } from '../../src/libs/archive/executor';
|
|
2
|
+
import { ArchiveMetadata, Subscription, MeterEvent, Invoice, InvoiceItem } from '../../src/store/models';
|
|
3
|
+
import { ArchiveLock } from '../../src/store/models/archive-lock';
|
|
4
|
+
import { openArchiveSequelize, ensureArchiveTable } from '../../src/libs/archive/store';
|
|
5
|
+
import * as configModule from '../../src/libs/archive/config';
|
|
6
|
+
|
|
7
|
+
const mockAcquireLock = jest.fn();
|
|
8
|
+
const mockReleaseLock = jest.fn();
|
|
9
|
+
|
|
10
|
+
jest.mock('../../src/libs/archive/lock', () => ({
|
|
11
|
+
acquireArchiveLock: (...args: any[]) => mockAcquireLock(...args),
|
|
12
|
+
releaseArchiveLock: (...args: any[]) => mockReleaseLock(...args),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock('@blocklet/sdk/lib/config', () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
default: {
|
|
18
|
+
env: {
|
|
19
|
+
preferences: {},
|
|
20
|
+
dataDir: '/tmp/test-data',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('../../src/store/models', () => ({
|
|
26
|
+
ArchiveMetadata: {
|
|
27
|
+
findAll: jest.fn(),
|
|
28
|
+
create: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
Customer: { findAll: jest.fn(), count: jest.fn(), min: jest.fn(), max: jest.fn(), rawAttributes: {} },
|
|
31
|
+
Subscription: { findAll: jest.fn().mockResolvedValue([]), rawAttributes: { status: {} } },
|
|
32
|
+
MeterEvent: {
|
|
33
|
+
findAll: jest.fn(),
|
|
34
|
+
destroy: jest.fn(),
|
|
35
|
+
count: jest.fn(),
|
|
36
|
+
min: jest.fn(),
|
|
37
|
+
max: jest.fn(),
|
|
38
|
+
rawAttributes: { status: {}, updated_at: {}, id: {} },
|
|
39
|
+
},
|
|
40
|
+
CreditTransaction: {
|
|
41
|
+
findAll: jest.fn(),
|
|
42
|
+
destroy: jest.fn(),
|
|
43
|
+
count: jest.fn(),
|
|
44
|
+
min: jest.fn(),
|
|
45
|
+
max: jest.fn(),
|
|
46
|
+
rawAttributes: { updated_at: {}, id: {} },
|
|
47
|
+
},
|
|
48
|
+
Event: {
|
|
49
|
+
findAll: jest.fn(),
|
|
50
|
+
destroy: jest.fn(),
|
|
51
|
+
count: jest.fn(),
|
|
52
|
+
min: jest.fn(),
|
|
53
|
+
max: jest.fn(),
|
|
54
|
+
rawAttributes: { created_at: {}, id: {} },
|
|
55
|
+
},
|
|
56
|
+
WebhookAttempt: {
|
|
57
|
+
findAll: jest.fn(),
|
|
58
|
+
destroy: jest.fn(),
|
|
59
|
+
count: jest.fn(),
|
|
60
|
+
min: jest.fn(),
|
|
61
|
+
max: jest.fn(),
|
|
62
|
+
rawAttributes: { created_at: {}, id: {} },
|
|
63
|
+
},
|
|
64
|
+
Job: {
|
|
65
|
+
findAll: jest.fn(),
|
|
66
|
+
destroy: jest.fn(),
|
|
67
|
+
count: jest.fn(),
|
|
68
|
+
min: jest.fn(),
|
|
69
|
+
max: jest.fn(),
|
|
70
|
+
rawAttributes: { created_at: {}, id: {} },
|
|
71
|
+
},
|
|
72
|
+
PaymentIntent: {
|
|
73
|
+
findAll: jest.fn(),
|
|
74
|
+
destroy: jest.fn(),
|
|
75
|
+
count: jest.fn(),
|
|
76
|
+
min: jest.fn(),
|
|
77
|
+
max: jest.fn(),
|
|
78
|
+
rawAttributes: { status: {}, updated_at: {}, id: {} },
|
|
79
|
+
},
|
|
80
|
+
Invoice: {
|
|
81
|
+
findAll: jest.fn(),
|
|
82
|
+
findOne: jest.fn().mockResolvedValue(null),
|
|
83
|
+
destroy: jest.fn(),
|
|
84
|
+
count: jest.fn(),
|
|
85
|
+
min: jest.fn(),
|
|
86
|
+
max: jest.fn(),
|
|
87
|
+
rawAttributes: { status: {}, updated_at: {}, id: {} },
|
|
88
|
+
},
|
|
89
|
+
InvoiceItem: {
|
|
90
|
+
findAll: jest.fn(),
|
|
91
|
+
destroy: jest.fn(),
|
|
92
|
+
count: jest.fn(),
|
|
93
|
+
min: jest.fn(),
|
|
94
|
+
max: jest.fn(),
|
|
95
|
+
rawAttributes: { updated_at: {}, id: {} },
|
|
96
|
+
},
|
|
97
|
+
Refund: {
|
|
98
|
+
findAll: jest.fn(),
|
|
99
|
+
destroy: jest.fn(),
|
|
100
|
+
count: jest.fn(),
|
|
101
|
+
min: jest.fn(),
|
|
102
|
+
max: jest.fn(),
|
|
103
|
+
rawAttributes: { status: {}, updated_at: {}, id: {} },
|
|
104
|
+
},
|
|
105
|
+
Payout: {
|
|
106
|
+
findAll: jest.fn(),
|
|
107
|
+
destroy: jest.fn(),
|
|
108
|
+
count: jest.fn(),
|
|
109
|
+
min: jest.fn(),
|
|
110
|
+
max: jest.fn(),
|
|
111
|
+
rawAttributes: { status: {}, updated_at: {}, id: {} },
|
|
112
|
+
},
|
|
113
|
+
CreditGrant: {
|
|
114
|
+
findAll: jest.fn(),
|
|
115
|
+
destroy: jest.fn(),
|
|
116
|
+
count: jest.fn(),
|
|
117
|
+
min: jest.fn(),
|
|
118
|
+
max: jest.fn(),
|
|
119
|
+
rawAttributes: { status: {}, updated_at: {}, id: {} },
|
|
120
|
+
},
|
|
121
|
+
Product: {
|
|
122
|
+
findAll: jest.fn(),
|
|
123
|
+
destroy: jest.fn(),
|
|
124
|
+
count: jest.fn(),
|
|
125
|
+
min: jest.fn(),
|
|
126
|
+
max: jest.fn(),
|
|
127
|
+
rawAttributes: { updated_at: {}, id: {} },
|
|
128
|
+
},
|
|
129
|
+
Price: {
|
|
130
|
+
findAll: jest.fn(),
|
|
131
|
+
destroy: jest.fn(),
|
|
132
|
+
count: jest.fn(),
|
|
133
|
+
min: jest.fn(),
|
|
134
|
+
max: jest.fn(),
|
|
135
|
+
rawAttributes: { updated_at: {}, id: {} },
|
|
136
|
+
},
|
|
137
|
+
Coupon: {
|
|
138
|
+
findAll: jest.fn(),
|
|
139
|
+
destroy: jest.fn(),
|
|
140
|
+
count: jest.fn(),
|
|
141
|
+
min: jest.fn(),
|
|
142
|
+
max: jest.fn(),
|
|
143
|
+
rawAttributes: { updated_at: {}, id: {} },
|
|
144
|
+
},
|
|
145
|
+
RevenueSnapshot: {
|
|
146
|
+
findAll: jest.fn().mockResolvedValue([]),
|
|
147
|
+
findOne: jest.fn().mockResolvedValue(null),
|
|
148
|
+
bulkCreate: jest.fn().mockResolvedValue([]),
|
|
149
|
+
},
|
|
150
|
+
PaymentCurrency: {
|
|
151
|
+
findAll: jest.fn().mockResolvedValue([]),
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
jest.mock('../../src/store/models/archive-metadata', () => ({
|
|
156
|
+
ArchiveMetadata: {
|
|
157
|
+
findAll: jest.fn(),
|
|
158
|
+
create: jest.fn(),
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
jest.mock('../../src/store/models/archive-lock', () => ({
|
|
163
|
+
ArchiveLock: {
|
|
164
|
+
update: jest.fn(),
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
jest.mock('../../src/store/sequelize', () => ({
|
|
169
|
+
sequelize: {
|
|
170
|
+
query: jest.fn(),
|
|
171
|
+
},
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
jest.mock('../../src/libs/archive/store', () => ({
|
|
175
|
+
getArchiveDir: jest.fn().mockReturnValue('/tmp/test-data/archive'),
|
|
176
|
+
getArchiveFilePath: jest.fn().mockReturnValue('/tmp/archive.db'),
|
|
177
|
+
getArchiveFilePathForYear: jest.fn().mockImplementation((year: number) => `/tmp/archive-${year}.db`),
|
|
178
|
+
getRecordYear: jest.fn().mockReturnValue(2024),
|
|
179
|
+
openArchiveSequelize: jest.fn(),
|
|
180
|
+
ensureArchiveTable: jest.fn(),
|
|
181
|
+
cleanupOldArchiveFiles: jest.fn().mockReturnValue([]),
|
|
182
|
+
getFileSize: jest.fn().mockReturnValue(1024),
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
jest.mock('fs', () => ({
|
|
186
|
+
...jest.requireActual('fs'),
|
|
187
|
+
readFileSync: jest.fn().mockReturnValue(Buffer.from('test')),
|
|
188
|
+
statfsSync: jest.fn().mockReturnValue({ bavail: 1000000, bsize: 1024 }),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
192
|
+
__esModule: true,
|
|
193
|
+
default: {
|
|
194
|
+
info: jest.fn(),
|
|
195
|
+
warn: jest.fn(),
|
|
196
|
+
error: jest.fn(),
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
const mockArchiveMetadata = ArchiveMetadata as jest.Mocked<typeof ArchiveMetadata>;
|
|
201
|
+
const mockArchiveLock = ArchiveLock as jest.Mocked<typeof ArchiveLock>;
|
|
202
|
+
const mockOpenArchiveSequelize = openArchiveSequelize as jest.MockedFunction<typeof openArchiveSequelize>;
|
|
203
|
+
const mockEnsureArchiveTable = ensureArchiveTable as jest.MockedFunction<typeof ensureArchiveTable>;
|
|
204
|
+
const mockSubscription = Subscription as jest.Mocked<typeof Subscription>;
|
|
205
|
+
const mockMeterEvent = MeterEvent as jest.Mocked<typeof MeterEvent>;
|
|
206
|
+
const mockInvoice = Invoice as jest.Mocked<typeof Invoice>;
|
|
207
|
+
const mockInvoiceItem = InvoiceItem as jest.Mocked<typeof InvoiceItem>;
|
|
208
|
+
|
|
209
|
+
describe('archive/executor', () => {
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
jest.clearAllMocks();
|
|
212
|
+
mockAcquireLock.mockReset();
|
|
213
|
+
mockReleaseLock.mockReset();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('recoverFromCrash', () => {
|
|
217
|
+
it('should do nothing when no stale jobs exist', async () => {
|
|
218
|
+
(mockArchiveMetadata.findAll as jest.Mock).mockResolvedValue([]);
|
|
219
|
+
|
|
220
|
+
await recoverFromCrash();
|
|
221
|
+
|
|
222
|
+
expect(mockArchiveMetadata.findAll).toHaveBeenCalledWith({ where: { status: 'in_progress' } });
|
|
223
|
+
expect(mockArchiveLock.update).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should recover stale in_progress jobs', async () => {
|
|
227
|
+
const staleJob = {
|
|
228
|
+
id: 'job-1',
|
|
229
|
+
archive_file: 'archive-2024.db',
|
|
230
|
+
created_at: new Date(Date.now() - 3600000),
|
|
231
|
+
update: jest.fn().mockResolvedValue({}),
|
|
232
|
+
};
|
|
233
|
+
(mockArchiveMetadata.findAll as jest.Mock).mockResolvedValue([staleJob]);
|
|
234
|
+
(mockArchiveLock.update as jest.Mock).mockResolvedValue([1]);
|
|
235
|
+
|
|
236
|
+
await recoverFromCrash();
|
|
237
|
+
|
|
238
|
+
expect(staleJob.update).toHaveBeenCalledWith(
|
|
239
|
+
expect.objectContaining({
|
|
240
|
+
status: 'failed',
|
|
241
|
+
error: 'Process crashed or restarted during archive job',
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
expect(mockArchiveLock.update).toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle errors gracefully', async () => {
|
|
248
|
+
(mockArchiveMetadata.findAll as jest.Mock).mockRejectedValue(new Error('DB error'));
|
|
249
|
+
|
|
250
|
+
await expect(recoverFromCrash()).resolves.not.toThrow();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('previewArchive', () => {
|
|
255
|
+
it('should return preview for enabled tables', async () => {
|
|
256
|
+
(mockMeterEvent.count as jest.Mock).mockResolvedValue(100);
|
|
257
|
+
(mockMeterEvent.min as jest.Mock).mockResolvedValue(new Date('2023-01-01'));
|
|
258
|
+
(mockMeterEvent.max as jest.Mock).mockResolvedValue(new Date('2023-12-31'));
|
|
259
|
+
|
|
260
|
+
const result = await previewArchive({ tables: ['meter_events'] });
|
|
261
|
+
|
|
262
|
+
expect(result).toHaveProperty('meter_events');
|
|
263
|
+
expect(result.meter_events).toHaveProperty('count');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should skip disabled tables', async () => {
|
|
267
|
+
const result = await previewArchive({ tables: ['customers'] });
|
|
268
|
+
|
|
269
|
+
expect(result).not.toHaveProperty('customers');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should handle empty tables array', async () => {
|
|
273
|
+
const result = await previewArchive({ tables: [] });
|
|
274
|
+
|
|
275
|
+
expect(typeof result).toBe('object');
|
|
276
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('runArchiveJob', () => {
|
|
281
|
+
it('should return disabled status when retention is not enabled', async () => {
|
|
282
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
283
|
+
enabled: false,
|
|
284
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
285
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
286
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
287
|
+
tables: {},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const result = await runArchiveJob({ triggeredBy: 'manual' });
|
|
291
|
+
|
|
292
|
+
expect(result).toEqual({ status: 'disabled' });
|
|
293
|
+
expect(mockAcquireLock).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should throw error when lock cannot be acquired', async () => {
|
|
297
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
298
|
+
enabled: true,
|
|
299
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
300
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
301
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
302
|
+
tables: { meter_events: { enabled: true, retentionDays: 90 } },
|
|
303
|
+
});
|
|
304
|
+
mockAcquireLock.mockResolvedValue(false);
|
|
305
|
+
|
|
306
|
+
await expect(runArchiveJob({ triggeredBy: 'manual' })).rejects.toThrow('Archive job already running');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should release lock on successful completion', async () => {
|
|
310
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
311
|
+
enabled: true,
|
|
312
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
313
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
314
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
315
|
+
tables: { meter_events: { enabled: true, retentionDays: 90 } },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
319
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
320
|
+
(mockSubscription.findAll as jest.Mock).mockResolvedValue([]);
|
|
321
|
+
(mockArchiveMetadata.create as jest.Mock).mockResolvedValue({
|
|
322
|
+
id: 'meta-1',
|
|
323
|
+
archive_file: 'archive-2026.db',
|
|
324
|
+
update: jest.fn().mockResolvedValue({}),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const mockArchiveDb = {
|
|
328
|
+
getQueryInterface: jest.fn().mockReturnValue({
|
|
329
|
+
bulkInsert: jest.fn().mockResolvedValue(undefined),
|
|
330
|
+
}),
|
|
331
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
332
|
+
};
|
|
333
|
+
mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
|
|
334
|
+
mockEnsureArchiveTable.mockResolvedValue(undefined);
|
|
335
|
+
(mockMeterEvent.findAll as jest.Mock).mockResolvedValue([]);
|
|
336
|
+
|
|
337
|
+
await runArchiveJob({ triggeredBy: 'manual', tables: ['meter_events'] });
|
|
338
|
+
|
|
339
|
+
expect(mockReleaseLock).toHaveBeenCalled();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should release lock even on error', async () => {
|
|
343
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
344
|
+
enabled: true,
|
|
345
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
346
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
347
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
348
|
+
tables: { meter_events: { enabled: true, retentionDays: 90 } },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
352
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
353
|
+
(mockArchiveMetadata.create as jest.Mock).mockRejectedValue(new Error('DB error'));
|
|
354
|
+
|
|
355
|
+
await expect(runArchiveJob({ triggeredBy: 'manual' })).rejects.toThrow('DB error');
|
|
356
|
+
expect(mockReleaseLock).toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should process dry run without deleting records', async () => {
|
|
360
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
361
|
+
enabled: true,
|
|
362
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
363
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
364
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
365
|
+
tables: { meter_events: { enabled: true, retentionDays: 90, archivableStatuses: ['completed'] } },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
369
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
370
|
+
(mockSubscription.findAll as jest.Mock).mockResolvedValue([]);
|
|
371
|
+
|
|
372
|
+
const mockMetadata = {
|
|
373
|
+
id: 'meta-1',
|
|
374
|
+
archive_file: 'archive-2026.db',
|
|
375
|
+
update: jest.fn().mockResolvedValue({}),
|
|
376
|
+
};
|
|
377
|
+
(mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);
|
|
378
|
+
|
|
379
|
+
const mockArchiveDb = {
|
|
380
|
+
getQueryInterface: jest.fn().mockReturnValue({
|
|
381
|
+
bulkInsert: jest.fn(),
|
|
382
|
+
}),
|
|
383
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
384
|
+
};
|
|
385
|
+
mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
|
|
386
|
+
mockEnsureArchiveTable.mockResolvedValue(undefined);
|
|
387
|
+
|
|
388
|
+
const mockRecord = {
|
|
389
|
+
id: 'evt-1',
|
|
390
|
+
status: 'completed',
|
|
391
|
+
updated_at: new Date('2020-01-01'),
|
|
392
|
+
toJSON: () => ({ id: 'evt-1', status: 'completed', updated_at: new Date('2020-01-01') }),
|
|
393
|
+
};
|
|
394
|
+
(mockMeterEvent.findAll as jest.Mock).mockResolvedValueOnce([mockRecord]).mockResolvedValue([]);
|
|
395
|
+
|
|
396
|
+
const result = await runArchiveJob({
|
|
397
|
+
triggeredBy: 'manual',
|
|
398
|
+
tables: ['meter_events'],
|
|
399
|
+
dryRun: true,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(result.status).toBe('dry_run_complete');
|
|
403
|
+
expect(mockMeterEvent.destroy).not.toHaveBeenCalled();
|
|
404
|
+
expect(mockArchiveDb.getQueryInterface().bulkInsert).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should skip records linked to active subscriptions', async () => {
|
|
408
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
409
|
+
enabled: true,
|
|
410
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
411
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
412
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
413
|
+
tables: {
|
|
414
|
+
meter_events: {
|
|
415
|
+
enabled: true,
|
|
416
|
+
retentionDays: 90,
|
|
417
|
+
archivableStatuses: ['completed'],
|
|
418
|
+
excludeConditions: { hasActiveSubscription: true },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
424
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
425
|
+
|
|
426
|
+
(mockSubscription.findAll as jest.Mock).mockResolvedValue([{ id: 'sub_active', customer_id: 'cus_active' }]);
|
|
427
|
+
|
|
428
|
+
const mockMetadata = {
|
|
429
|
+
id: 'meta-1',
|
|
430
|
+
archive_file: 'archive-2026.db',
|
|
431
|
+
update: jest.fn().mockResolvedValue({}),
|
|
432
|
+
};
|
|
433
|
+
(mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);
|
|
434
|
+
|
|
435
|
+
const mockArchiveDb = {
|
|
436
|
+
getQueryInterface: jest.fn().mockReturnValue({
|
|
437
|
+
bulkInsert: jest.fn(),
|
|
438
|
+
}),
|
|
439
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
440
|
+
};
|
|
441
|
+
mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
|
|
442
|
+
mockEnsureArchiveTable.mockResolvedValue(undefined);
|
|
443
|
+
|
|
444
|
+
const linkedRecord = {
|
|
445
|
+
id: 'evt-linked',
|
|
446
|
+
status: 'completed',
|
|
447
|
+
updated_at: new Date('2020-01-01'),
|
|
448
|
+
subscription_id: 'sub_active',
|
|
449
|
+
toJSON: () => ({
|
|
450
|
+
id: 'evt-linked',
|
|
451
|
+
status: 'completed',
|
|
452
|
+
updated_at: new Date('2020-01-01'),
|
|
453
|
+
subscription_id: 'sub_active',
|
|
454
|
+
}),
|
|
455
|
+
};
|
|
456
|
+
const unlinkedRecord = {
|
|
457
|
+
id: 'evt-unlinked',
|
|
458
|
+
status: 'completed',
|
|
459
|
+
updated_at: new Date('2020-01-01'),
|
|
460
|
+
subscription_id: null,
|
|
461
|
+
toJSON: () => ({
|
|
462
|
+
id: 'evt-unlinked',
|
|
463
|
+
status: 'completed',
|
|
464
|
+
updated_at: new Date('2020-01-01'),
|
|
465
|
+
subscription_id: null,
|
|
466
|
+
}),
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
(mockMeterEvent.findAll as jest.Mock).mockResolvedValueOnce([linkedRecord, unlinkedRecord]).mockResolvedValue([]);
|
|
470
|
+
(mockMeterEvent.destroy as jest.Mock).mockResolvedValue(1);
|
|
471
|
+
|
|
472
|
+
const result = await runArchiveJob({
|
|
473
|
+
triggeredBy: 'manual',
|
|
474
|
+
tables: ['meter_events'],
|
|
475
|
+
dryRun: true,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(result.status).toBe('dry_run_complete');
|
|
479
|
+
expect(result.tables?.meter_events?.archived_count).toBeGreaterThanOrEqual(1);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should archive parent and cascade child tables with consistent counts', async () => {
|
|
483
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
484
|
+
enabled: true,
|
|
485
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
486
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
487
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
488
|
+
tables: {
|
|
489
|
+
invoices: {
|
|
490
|
+
enabled: true,
|
|
491
|
+
retentionDays: 365,
|
|
492
|
+
archivableStatuses: ['paid', 'void'],
|
|
493
|
+
cascadeRelations: { invoice_items: 'invoice_id' },
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
499
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
500
|
+
(mockSubscription.findAll as jest.Mock).mockResolvedValue([]);
|
|
501
|
+
|
|
502
|
+
const mockMetadata = {
|
|
503
|
+
id: 'meta-1',
|
|
504
|
+
archive_file: 'archive-2026.db',
|
|
505
|
+
update: jest.fn().mockResolvedValue({}),
|
|
506
|
+
};
|
|
507
|
+
(mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);
|
|
508
|
+
|
|
509
|
+
const mockBulkInsert = jest.fn().mockResolvedValue(undefined);
|
|
510
|
+
const mockArchiveDb = {
|
|
511
|
+
getQueryInterface: jest.fn().mockReturnValue({ bulkInsert: mockBulkInsert }),
|
|
512
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
513
|
+
};
|
|
514
|
+
mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
|
|
515
|
+
mockEnsureArchiveTable.mockResolvedValue(undefined);
|
|
516
|
+
|
|
517
|
+
const mockInvoiceRecord = {
|
|
518
|
+
id: 'inv-1',
|
|
519
|
+
status: 'paid',
|
|
520
|
+
updated_at: new Date('2020-01-01'),
|
|
521
|
+
toJSON: () => ({ id: 'inv-1', status: 'paid', updated_at: new Date('2020-01-01') }),
|
|
522
|
+
};
|
|
523
|
+
(mockInvoice.findAll as jest.Mock).mockResolvedValueOnce([mockInvoiceRecord]).mockResolvedValue([]);
|
|
524
|
+
(mockInvoice.destroy as jest.Mock).mockResolvedValue(1);
|
|
525
|
+
|
|
526
|
+
const mockItemRecord = {
|
|
527
|
+
id: 'ii-1',
|
|
528
|
+
invoice_id: 'inv-1',
|
|
529
|
+
toJSON: () => ({ id: 'ii-1', invoice_id: 'inv-1' }),
|
|
530
|
+
};
|
|
531
|
+
(mockInvoiceItem.findAll as jest.Mock).mockResolvedValue([mockItemRecord]);
|
|
532
|
+
(mockInvoiceItem.destroy as jest.Mock).mockResolvedValue(1);
|
|
533
|
+
|
|
534
|
+
const result = await runArchiveJob({ triggeredBy: 'manual', tables: ['invoices'] });
|
|
535
|
+
|
|
536
|
+
expect(result.status).toBe('completed');
|
|
537
|
+
// Parent: archived and deleted counts match
|
|
538
|
+
expect(result.tables?.invoices?.archived_count).toBe(1);
|
|
539
|
+
expect(result.tables?.invoices?.deleted_count).toBe(1);
|
|
540
|
+
// Child: cascade archived and deleted counts match
|
|
541
|
+
expect(result.tables?.invoice_items?.archived_count).toBe(1);
|
|
542
|
+
expect(result.tables?.invoice_items?.deleted_count).toBe(1);
|
|
543
|
+
// bulkInsert called with ignoreDuplicates for both parent and child
|
|
544
|
+
expect(mockBulkInsert).toHaveBeenCalledTimes(2);
|
|
545
|
+
expect(mockBulkInsert).toHaveBeenCalledWith('invoices', expect.any(Array), { ignoreDuplicates: true });
|
|
546
|
+
expect(mockBulkInsert).toHaveBeenCalledWith('invoice_items', expect.any(Array), { ignoreDuplicates: true });
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('should not inflate parent archived_count when cascade child insert fails', async () => {
|
|
550
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
551
|
+
enabled: true,
|
|
552
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
553
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
554
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
555
|
+
tables: {
|
|
556
|
+
invoices: {
|
|
557
|
+
enabled: true,
|
|
558
|
+
retentionDays: 365,
|
|
559
|
+
archivableStatuses: ['paid'],
|
|
560
|
+
cascadeRelations: { invoice_items: 'invoice_id' },
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
566
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
567
|
+
(mockSubscription.findAll as jest.Mock).mockResolvedValue([]);
|
|
568
|
+
|
|
569
|
+
const mockMetadata = {
|
|
570
|
+
id: 'meta-1',
|
|
571
|
+
archive_file: 'archive-2026.db',
|
|
572
|
+
update: jest.fn().mockResolvedValue({}),
|
|
573
|
+
};
|
|
574
|
+
(mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);
|
|
575
|
+
|
|
576
|
+
const mockBulkInsert = jest
|
|
577
|
+
.fn()
|
|
578
|
+
.mockResolvedValueOnce(undefined) // parent insert succeeds
|
|
579
|
+
.mockRejectedValueOnce(new Error('child insert error')); // child insert fails
|
|
580
|
+
const mockArchiveDb = {
|
|
581
|
+
getQueryInterface: jest.fn().mockReturnValue({ bulkInsert: mockBulkInsert }),
|
|
582
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
583
|
+
};
|
|
584
|
+
mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
|
|
585
|
+
mockEnsureArchiveTable.mockResolvedValue(undefined);
|
|
586
|
+
|
|
587
|
+
const mockInvoiceRecord = {
|
|
588
|
+
id: 'inv-1',
|
|
589
|
+
status: 'paid',
|
|
590
|
+
updated_at: new Date('2020-01-01'),
|
|
591
|
+
toJSON: () => ({ id: 'inv-1', status: 'paid', updated_at: new Date('2020-01-01') }),
|
|
592
|
+
};
|
|
593
|
+
(mockInvoice.findAll as jest.Mock).mockResolvedValueOnce([mockInvoiceRecord]).mockResolvedValue([]);
|
|
594
|
+
|
|
595
|
+
const mockItemRecord = {
|
|
596
|
+
id: 'ii-1',
|
|
597
|
+
invoice_id: 'inv-1',
|
|
598
|
+
toJSON: () => ({ id: 'ii-1', invoice_id: 'inv-1' }),
|
|
599
|
+
};
|
|
600
|
+
(mockInvoiceItem.findAll as jest.Mock).mockResolvedValue([mockItemRecord]);
|
|
601
|
+
|
|
602
|
+
// Cascade failure should NOT cause consistency check to throw because
|
|
603
|
+
// archived_count is now accumulated after destroy (which never happens)
|
|
604
|
+
const result = await runArchiveJob({ triggeredBy: 'manual', tables: ['invoices'] });
|
|
605
|
+
|
|
606
|
+
expect(result.status).toBe('completed');
|
|
607
|
+
// Parent: archived_count=0 because destroy never executed (cascade failed → break)
|
|
608
|
+
expect(result.tables?.invoices?.archived_count).toBe(0);
|
|
609
|
+
expect(result.tables?.invoices?.deleted_count).toBe(0);
|
|
610
|
+
// Parent was NOT deleted from main DB
|
|
611
|
+
expect(mockInvoice.destroy).not.toHaveBeenCalled();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('should not inflate child archived_count when cascade child delete fails', async () => {
|
|
615
|
+
jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
|
|
616
|
+
enabled: true,
|
|
617
|
+
defaults: { retentionDays: 365, batchSize: 500 },
|
|
618
|
+
schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
|
|
619
|
+
storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
|
|
620
|
+
tables: {
|
|
621
|
+
invoices: {
|
|
622
|
+
enabled: true,
|
|
623
|
+
retentionDays: 365,
|
|
624
|
+
archivableStatuses: ['paid'],
|
|
625
|
+
cascadeRelations: { invoice_items: 'invoice_id' },
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
mockAcquireLock.mockResolvedValue(true);
|
|
631
|
+
mockReleaseLock.mockResolvedValue(undefined);
|
|
632
|
+
(mockSubscription.findAll as jest.Mock).mockResolvedValue([]);
|
|
633
|
+
|
|
634
|
+
const mockMetadata = {
|
|
635
|
+
id: 'meta-1',
|
|
636
|
+
archive_file: 'archive-2026.db',
|
|
637
|
+
update: jest.fn().mockResolvedValue({}),
|
|
638
|
+
};
|
|
639
|
+
(mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);
|
|
640
|
+
|
|
641
|
+
const mockBulkInsert = jest.fn().mockResolvedValue(undefined); // both inserts succeed
|
|
642
|
+
const mockArchiveDb = {
|
|
643
|
+
getQueryInterface: jest.fn().mockReturnValue({ bulkInsert: mockBulkInsert }),
|
|
644
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
645
|
+
};
|
|
646
|
+
mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
|
|
647
|
+
mockEnsureArchiveTable.mockResolvedValue(undefined);
|
|
648
|
+
|
|
649
|
+
const mockInvoiceRecord = {
|
|
650
|
+
id: 'inv-1',
|
|
651
|
+
status: 'paid',
|
|
652
|
+
updated_at: new Date('2020-01-01'),
|
|
653
|
+
toJSON: () => ({ id: 'inv-1', status: 'paid', updated_at: new Date('2020-01-01') }),
|
|
654
|
+
};
|
|
655
|
+
(mockInvoice.findAll as jest.Mock).mockResolvedValueOnce([mockInvoiceRecord]).mockResolvedValue([]);
|
|
656
|
+
|
|
657
|
+
const mockItemRecord = {
|
|
658
|
+
id: 'ii-1',
|
|
659
|
+
invoice_id: 'inv-1',
|
|
660
|
+
toJSON: () => ({ id: 'ii-1', invoice_id: 'inv-1' }),
|
|
661
|
+
};
|
|
662
|
+
(mockInvoiceItem.findAll as jest.Mock).mockResolvedValue([mockItemRecord]);
|
|
663
|
+
// Child delete fails
|
|
664
|
+
(mockInvoiceItem.destroy as jest.Mock).mockRejectedValue(new Error('child delete error'));
|
|
665
|
+
|
|
666
|
+
const result = await runArchiveJob({ triggeredBy: 'manual', tables: ['invoices'] });
|
|
667
|
+
|
|
668
|
+
expect(result.status).toBe('completed');
|
|
669
|
+
// Both parent and child counts should be 0 (cascade failed → break → no delete)
|
|
670
|
+
expect(result.tables?.invoices?.archived_count).toBe(0);
|
|
671
|
+
expect(result.tables?.invoices?.deleted_count).toBe(0);
|
|
672
|
+
expect(result.tables?.invoice_items?.archived_count).toBe(0);
|
|
673
|
+
expect(result.tables?.invoice_items?.deleted_count).toBe(0);
|
|
674
|
+
// Parent was NOT deleted from main DB
|
|
675
|
+
expect(mockInvoice.destroy).not.toHaveBeenCalled();
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
});
|