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,130 @@
|
|
|
1
|
+
import { acquireArchiveLock, releaseArchiveLock } from '../../src/libs/archive/lock';
|
|
2
|
+
import { ArchiveLock } from '../../src/store/models/archive-lock';
|
|
3
|
+
|
|
4
|
+
jest.mock('../../src/store/models/archive-lock', () => {
|
|
5
|
+
const mockArchiveLock = {
|
|
6
|
+
sequelize: {
|
|
7
|
+
transaction: jest.fn(),
|
|
8
|
+
},
|
|
9
|
+
findByPk: jest.fn(),
|
|
10
|
+
create: jest.fn(),
|
|
11
|
+
update: jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
return { ArchiveLock: mockArchiveLock };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('archive/lock', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('acquireArchiveLock', () => {
|
|
22
|
+
it('should return false when sequelize is not available', async () => {
|
|
23
|
+
(ArchiveLock as any).sequelize = null;
|
|
24
|
+
|
|
25
|
+
const result = await acquireArchiveLock('instance-1');
|
|
26
|
+
|
|
27
|
+
expect(result).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should create new lock when no existing lock', async () => {
|
|
31
|
+
const mockTransaction = jest.fn();
|
|
32
|
+
(ArchiveLock as any).sequelize = {
|
|
33
|
+
transaction: jest.fn((callback: any) => callback(mockTransaction)),
|
|
34
|
+
};
|
|
35
|
+
(ArchiveLock.findByPk as jest.Mock).mockResolvedValue(null);
|
|
36
|
+
(ArchiveLock.create as jest.Mock).mockResolvedValue({});
|
|
37
|
+
|
|
38
|
+
const result = await acquireArchiveLock('instance-1');
|
|
39
|
+
|
|
40
|
+
expect(result).toBe(true);
|
|
41
|
+
expect(ArchiveLock.create).toHaveBeenCalledWith(
|
|
42
|
+
expect.objectContaining({
|
|
43
|
+
id: 'archive_job',
|
|
44
|
+
locked_by: 'instance-1',
|
|
45
|
+
}),
|
|
46
|
+
expect.any(Object)
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should acquire expired lock', async () => {
|
|
51
|
+
const mockTransaction = jest.fn();
|
|
52
|
+
const expiredLock = {
|
|
53
|
+
expires_at: Date.now() - 1000,
|
|
54
|
+
update: jest.fn().mockResolvedValue({}),
|
|
55
|
+
};
|
|
56
|
+
(ArchiveLock as any).sequelize = {
|
|
57
|
+
transaction: jest.fn((callback: any) => callback(mockTransaction)),
|
|
58
|
+
};
|
|
59
|
+
(ArchiveLock.findByPk as jest.Mock).mockResolvedValue(expiredLock);
|
|
60
|
+
|
|
61
|
+
const result = await acquireArchiveLock('instance-2');
|
|
62
|
+
|
|
63
|
+
expect(result).toBe(true);
|
|
64
|
+
expect(expiredLock.update).toHaveBeenCalledWith(
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
locked_by: 'instance-2',
|
|
67
|
+
}),
|
|
68
|
+
expect.any(Object)
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not acquire lock when held by another instance', async () => {
|
|
73
|
+
const mockTransaction = jest.fn();
|
|
74
|
+
const activeLock = {
|
|
75
|
+
expires_at: Date.now() + 60000,
|
|
76
|
+
locked_by: 'other-instance',
|
|
77
|
+
};
|
|
78
|
+
(ArchiveLock as any).sequelize = {
|
|
79
|
+
transaction: jest.fn((callback: any) => callback(mockTransaction)),
|
|
80
|
+
};
|
|
81
|
+
(ArchiveLock.findByPk as jest.Mock).mockResolvedValue(activeLock);
|
|
82
|
+
|
|
83
|
+
const result = await acquireArchiveLock('instance-1');
|
|
84
|
+
|
|
85
|
+
expect(result).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should acquire lock when expires_at is null (stale lock)', async () => {
|
|
89
|
+
const mockTransaction = jest.fn();
|
|
90
|
+
const staleLock = {
|
|
91
|
+
expires_at: null,
|
|
92
|
+
update: jest.fn().mockResolvedValue({}),
|
|
93
|
+
};
|
|
94
|
+
(ArchiveLock as any).sequelize = {
|
|
95
|
+
transaction: jest.fn((callback: any) => callback(mockTransaction)),
|
|
96
|
+
};
|
|
97
|
+
(ArchiveLock.findByPk as jest.Mock).mockResolvedValue(staleLock);
|
|
98
|
+
|
|
99
|
+
const result = await acquireArchiveLock('instance-1');
|
|
100
|
+
|
|
101
|
+
expect(result).toBe(true);
|
|
102
|
+
expect(staleLock.update).toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('releaseArchiveLock', () => {
|
|
107
|
+
it('should release lock for the given instance', async () => {
|
|
108
|
+
(ArchiveLock.update as jest.Mock).mockResolvedValue([1]);
|
|
109
|
+
(ArchiveLock as any).sequelize = { transaction: jest.fn() };
|
|
110
|
+
|
|
111
|
+
await releaseArchiveLock('instance-1');
|
|
112
|
+
|
|
113
|
+
expect(ArchiveLock.update).toHaveBeenCalledWith(
|
|
114
|
+
{ locked_by: null, locked_at: null, expires_at: null },
|
|
115
|
+
{ where: { id: 'archive_job', locked_by: 'instance-1' } }
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should not release lock held by different instance', async () => {
|
|
120
|
+
(ArchiveLock.update as jest.Mock).mockResolvedValue([0]);
|
|
121
|
+
(ArchiveLock as any).sequelize = { transaction: jest.fn() };
|
|
122
|
+
|
|
123
|
+
await releaseArchiveLock('wrong-instance');
|
|
124
|
+
|
|
125
|
+
expect(ArchiveLock.update).toHaveBeenCalledWith(expect.anything(), {
|
|
126
|
+
where: { id: 'archive_job', locked_by: 'wrong-instance' },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Op } from 'sequelize';
|
|
2
|
+
import { getArchiveDateField, buildArchiveQueryPlan } from '../../src/libs/archive/policy';
|
|
3
|
+
import { TableRetentionPolicy } from '../../src/libs/archive/config';
|
|
4
|
+
|
|
5
|
+
describe('archive/policy', () => {
|
|
6
|
+
describe('getArchiveDateField', () => {
|
|
7
|
+
it('should return updated_at when model has updated_at attribute', () => {
|
|
8
|
+
const mockModel = {
|
|
9
|
+
rawAttributes: {
|
|
10
|
+
id: {},
|
|
11
|
+
updated_at: {},
|
|
12
|
+
created_at: {},
|
|
13
|
+
},
|
|
14
|
+
} as any;
|
|
15
|
+
|
|
16
|
+
expect(getArchiveDateField(mockModel)).toBe('updated_at');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return created_at when model only has created_at attribute', () => {
|
|
20
|
+
const mockModel = {
|
|
21
|
+
rawAttributes: {
|
|
22
|
+
id: {},
|
|
23
|
+
created_at: {},
|
|
24
|
+
},
|
|
25
|
+
} as any;
|
|
26
|
+
|
|
27
|
+
expect(getArchiveDateField(mockModel)).toBe('created_at');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should fallback to created_at when neither field exists', () => {
|
|
31
|
+
const mockModel = {
|
|
32
|
+
rawAttributes: {
|
|
33
|
+
id: {},
|
|
34
|
+
name: {},
|
|
35
|
+
},
|
|
36
|
+
} as any;
|
|
37
|
+
|
|
38
|
+
expect(getArchiveDateField(mockModel)).toBe('created_at');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('buildArchiveQueryPlan', () => {
|
|
43
|
+
const cutoffDate = new Date('2024-01-01T00:00:00Z');
|
|
44
|
+
|
|
45
|
+
it('should build basic query plan with date cutoff', () => {
|
|
46
|
+
const mockModel = {
|
|
47
|
+
rawAttributes: {
|
|
48
|
+
id: {},
|
|
49
|
+
created_at: {},
|
|
50
|
+
},
|
|
51
|
+
} as any;
|
|
52
|
+
|
|
53
|
+
const policy: TableRetentionPolicy = {
|
|
54
|
+
enabled: true,
|
|
55
|
+
retentionDays: 90,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const { where, dateField } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
59
|
+
|
|
60
|
+
expect(dateField).toBe('created_at');
|
|
61
|
+
expect(where.created_at).toBeDefined();
|
|
62
|
+
expect((where.created_at as any)[Op.lte]).toEqual(cutoffDate);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should use updated_at when available', () => {
|
|
66
|
+
const mockModel = {
|
|
67
|
+
rawAttributes: {
|
|
68
|
+
id: {},
|
|
69
|
+
updated_at: {},
|
|
70
|
+
created_at: {},
|
|
71
|
+
},
|
|
72
|
+
} as any;
|
|
73
|
+
|
|
74
|
+
const policy: TableRetentionPolicy = {
|
|
75
|
+
enabled: true,
|
|
76
|
+
retentionDays: 90,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const { where, dateField } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
80
|
+
|
|
81
|
+
expect(dateField).toBe('updated_at');
|
|
82
|
+
expect(where.updated_at).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should include archivableStatuses condition when model has status field', () => {
|
|
86
|
+
const mockModel = {
|
|
87
|
+
rawAttributes: {
|
|
88
|
+
id: {},
|
|
89
|
+
status: {},
|
|
90
|
+
created_at: {},
|
|
91
|
+
},
|
|
92
|
+
} as any;
|
|
93
|
+
|
|
94
|
+
const policy: TableRetentionPolicy = {
|
|
95
|
+
enabled: true,
|
|
96
|
+
retentionDays: 90,
|
|
97
|
+
archivableStatuses: ['completed', 'canceled'],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
101
|
+
const conditions = (where as any)[Op.and];
|
|
102
|
+
|
|
103
|
+
expect(conditions).toBeDefined();
|
|
104
|
+
expect(conditions).toContainEqual({
|
|
105
|
+
status: { [Op.in]: ['completed', 'canceled'] },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should include excludeConditions.statuses when model has status field', () => {
|
|
110
|
+
const mockModel = {
|
|
111
|
+
rawAttributes: {
|
|
112
|
+
id: {},
|
|
113
|
+
status: {},
|
|
114
|
+
created_at: {},
|
|
115
|
+
},
|
|
116
|
+
} as any;
|
|
117
|
+
|
|
118
|
+
const policy: TableRetentionPolicy = {
|
|
119
|
+
enabled: true,
|
|
120
|
+
retentionDays: 90,
|
|
121
|
+
excludeConditions: {
|
|
122
|
+
statuses: ['pending', 'processing'],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
127
|
+
const conditions = (where as any)[Op.and];
|
|
128
|
+
|
|
129
|
+
expect(conditions).toBeDefined();
|
|
130
|
+
expect(conditions).toContainEqual({
|
|
131
|
+
status: { [Op.notIn]: ['pending', 'processing'] },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should not add status conditions when model lacks status field', () => {
|
|
136
|
+
const mockModel = {
|
|
137
|
+
rawAttributes: {
|
|
138
|
+
id: {},
|
|
139
|
+
created_at: {},
|
|
140
|
+
},
|
|
141
|
+
} as any;
|
|
142
|
+
|
|
143
|
+
const policy: TableRetentionPolicy = {
|
|
144
|
+
enabled: true,
|
|
145
|
+
retentionDays: 90,
|
|
146
|
+
archivableStatuses: ['completed'],
|
|
147
|
+
excludeConditions: {
|
|
148
|
+
statuses: ['pending'],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
153
|
+
const conditions = (where as any)[Op.and];
|
|
154
|
+
|
|
155
|
+
expect(conditions).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should include custom condition wrapped with NOT()', () => {
|
|
159
|
+
const mockModel = {
|
|
160
|
+
rawAttributes: {
|
|
161
|
+
id: {},
|
|
162
|
+
created_at: {},
|
|
163
|
+
},
|
|
164
|
+
} as any;
|
|
165
|
+
|
|
166
|
+
const policy: TableRetentionPolicy = {
|
|
167
|
+
enabled: true,
|
|
168
|
+
retentionDays: 90,
|
|
169
|
+
excludeConditions: {
|
|
170
|
+
customCondition: 'pending_webhooks > 0',
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
175
|
+
const conditions = (where as any)[Op.and];
|
|
176
|
+
|
|
177
|
+
expect(conditions).toBeDefined();
|
|
178
|
+
expect(conditions.length).toBe(1);
|
|
179
|
+
// Verify the Sequelize.literal wraps with NOT()
|
|
180
|
+
const literal = conditions[0];
|
|
181
|
+
expect(literal.val).toContain('NOT');
|
|
182
|
+
expect(literal.val).toContain('pending_webhooks > 0');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should combine multiple conditions with AND', () => {
|
|
186
|
+
const mockModel = {
|
|
187
|
+
rawAttributes: {
|
|
188
|
+
id: {},
|
|
189
|
+
status: {},
|
|
190
|
+
created_at: {},
|
|
191
|
+
},
|
|
192
|
+
} as any;
|
|
193
|
+
|
|
194
|
+
const policy: TableRetentionPolicy = {
|
|
195
|
+
enabled: true,
|
|
196
|
+
retentionDays: 90,
|
|
197
|
+
archivableStatuses: ['completed'],
|
|
198
|
+
excludeConditions: {
|
|
199
|
+
statuses: ['pending'],
|
|
200
|
+
customCondition: 'active = 0',
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
205
|
+
const conditions = (where as any)[Op.and];
|
|
206
|
+
|
|
207
|
+
expect(conditions).toBeDefined();
|
|
208
|
+
expect(conditions.length).toBe(3);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle empty archivableStatuses array', () => {
|
|
212
|
+
const mockModel = {
|
|
213
|
+
rawAttributes: {
|
|
214
|
+
id: {},
|
|
215
|
+
status: {},
|
|
216
|
+
created_at: {},
|
|
217
|
+
},
|
|
218
|
+
} as any;
|
|
219
|
+
|
|
220
|
+
const policy: TableRetentionPolicy = {
|
|
221
|
+
enabled: true,
|
|
222
|
+
retentionDays: 90,
|
|
223
|
+
archivableStatuses: [],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
227
|
+
const conditions = (where as any)[Op.and];
|
|
228
|
+
|
|
229
|
+
expect(conditions).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should handle empty excludeConditions.statuses array', () => {
|
|
233
|
+
const mockModel = {
|
|
234
|
+
rawAttributes: {
|
|
235
|
+
id: {},
|
|
236
|
+
status: {},
|
|
237
|
+
created_at: {},
|
|
238
|
+
},
|
|
239
|
+
} as any;
|
|
240
|
+
|
|
241
|
+
const policy: TableRetentionPolicy = {
|
|
242
|
+
enabled: true,
|
|
243
|
+
retentionDays: 90,
|
|
244
|
+
excludeConditions: {
|
|
245
|
+
statuses: [],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const { where } = buildArchiveQueryPlan(mockModel, policy, cutoffDate);
|
|
250
|
+
const conditions = (where as any)[Op.and];
|
|
251
|
+
|
|
252
|
+
expect(conditions).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { queryArchive } from '../../src/libs/archive/query';
|
|
2
|
+
|
|
3
|
+
import { listArchiveFiles, openArchiveSequelize } from '../../src/libs/archive/store';
|
|
4
|
+
import { ArchiveMetadata } from '../../src/store/models/archive-metadata';
|
|
5
|
+
|
|
6
|
+
jest.mock('../../src/libs/archive/store', () => ({
|
|
7
|
+
listArchiveFiles: jest.fn(),
|
|
8
|
+
openArchiveSequelize: jest.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
jest.mock('../../src/store/models/archive-metadata', () => ({
|
|
12
|
+
ArchiveMetadata: {
|
|
13
|
+
findOne: jest.fn(),
|
|
14
|
+
findAll: jest.fn(),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
default: {
|
|
21
|
+
info: jest.fn(),
|
|
22
|
+
warn: jest.fn(),
|
|
23
|
+
error: jest.fn(),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const mockListArchiveFiles = listArchiveFiles as jest.MockedFunction<typeof listArchiveFiles>;
|
|
28
|
+
const mockOpenArchiveSequelize = openArchiveSequelize as jest.MockedFunction<typeof openArchiveSequelize>;
|
|
29
|
+
const mockArchiveMetadataFindAll = (ArchiveMetadata as any).findAll as jest.MockedFunction<any>;
|
|
30
|
+
|
|
31
|
+
describe('archive/query', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('queryArchive', () => {
|
|
37
|
+
it('should reject invalid table names', async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
queryArchive({
|
|
40
|
+
table: 'DROP TABLE users;--',
|
|
41
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
42
|
+
page: 1,
|
|
43
|
+
limit: 10,
|
|
44
|
+
})
|
|
45
|
+
).rejects.toThrow('Invalid table name');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should reject table names starting with numbers', async () => {
|
|
49
|
+
await expect(
|
|
50
|
+
queryArchive({
|
|
51
|
+
table: '123_table',
|
|
52
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
53
|
+
page: 1,
|
|
54
|
+
limit: 10,
|
|
55
|
+
})
|
|
56
|
+
).rejects.toThrow('Invalid table name');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should accept valid table names', async () => {
|
|
60
|
+
mockListArchiveFiles.mockReturnValue([]);
|
|
61
|
+
|
|
62
|
+
const result = await queryArchive({
|
|
63
|
+
table: 'meter_events',
|
|
64
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
65
|
+
page: 1,
|
|
66
|
+
limit: 10,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual({ data: [], total: 0, archiveFiles: [] });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return empty result when no archive files exist', async () => {
|
|
73
|
+
mockListArchiveFiles.mockReturnValue([]);
|
|
74
|
+
|
|
75
|
+
const result = await queryArchive({
|
|
76
|
+
table: 'invoices',
|
|
77
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
78
|
+
page: 1,
|
|
79
|
+
limit: 10,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.data).toHaveLength(0);
|
|
83
|
+
expect(result.total).toBe(0);
|
|
84
|
+
expect(result.archiveFiles).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should query archive files and return paginated results', async () => {
|
|
88
|
+
const mockClose = jest.fn();
|
|
89
|
+
// Mock query: first call checks table existence, second call fetches data
|
|
90
|
+
const mockQuery = jest.fn().mockImplementation((sql: string) => {
|
|
91
|
+
if (sql.includes('sqlite_master')) {
|
|
92
|
+
return [[{ name: 'invoices' }]]; // Table exists
|
|
93
|
+
}
|
|
94
|
+
return [
|
|
95
|
+
{ id: '1', created_at: new Date('2024-01-15') },
|
|
96
|
+
{ id: '2', created_at: new Date('2024-01-14') },
|
|
97
|
+
];
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
101
|
+
mockOpenArchiveSequelize.mockReturnValue({
|
|
102
|
+
query: mockQuery,
|
|
103
|
+
close: mockClose,
|
|
104
|
+
} as any);
|
|
105
|
+
mockArchiveMetadataFindAll.mockResolvedValue([
|
|
106
|
+
{
|
|
107
|
+
id: 'meta-1',
|
|
108
|
+
query_count: 0,
|
|
109
|
+
query_actor_ids: [],
|
|
110
|
+
update: jest.fn(),
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const result = await queryArchive({
|
|
115
|
+
table: 'invoices',
|
|
116
|
+
from: Math.floor(new Date('2024-01-01').getTime() / 1000),
|
|
117
|
+
to: Math.floor(new Date('2024-01-31').getTime() / 1000),
|
|
118
|
+
page: 1,
|
|
119
|
+
limit: 10,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.data).toHaveLength(2);
|
|
123
|
+
expect(result.total).toBe(2);
|
|
124
|
+
expect(mockClose).toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should skip archive files that do not have the requested table', async () => {
|
|
128
|
+
const mockClose = jest.fn();
|
|
129
|
+
// Return empty array for sqlite_master query (table doesn't exist)
|
|
130
|
+
const mockQuery = jest.fn().mockResolvedValue([[]]);
|
|
131
|
+
|
|
132
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
133
|
+
mockOpenArchiveSequelize.mockReturnValue({
|
|
134
|
+
query: mockQuery,
|
|
135
|
+
close: mockClose,
|
|
136
|
+
} as any);
|
|
137
|
+
|
|
138
|
+
const result = await queryArchive({
|
|
139
|
+
table: 'nonexistent_table',
|
|
140
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
141
|
+
page: 1,
|
|
142
|
+
limit: 10,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result.data).toHaveLength(0);
|
|
146
|
+
expect(mockClose).toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should handle query errors gracefully', async () => {
|
|
150
|
+
const mockClose = jest.fn();
|
|
151
|
+
const mockQuery = jest.fn().mockImplementation((sql: string) => {
|
|
152
|
+
if (sql.includes('sqlite_master')) {
|
|
153
|
+
return [[{ name: 'invoices' }]]; // Table exists
|
|
154
|
+
}
|
|
155
|
+
throw new Error('Query failed');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
159
|
+
mockOpenArchiveSequelize.mockReturnValue({
|
|
160
|
+
query: mockQuery,
|
|
161
|
+
close: mockClose,
|
|
162
|
+
} as any);
|
|
163
|
+
|
|
164
|
+
const result = await queryArchive({
|
|
165
|
+
table: 'invoices',
|
|
166
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
167
|
+
page: 1,
|
|
168
|
+
limit: 10,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.data).toHaveLength(0);
|
|
172
|
+
expect(mockClose).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should apply customer_id filter correctly for regular tables', async () => {
|
|
176
|
+
const mockClose = jest.fn();
|
|
177
|
+
const mockQuery = jest.fn().mockImplementation((sql: string) => {
|
|
178
|
+
if (sql.includes('sqlite_master')) {
|
|
179
|
+
return [[{ name: 'invoices' }]];
|
|
180
|
+
}
|
|
181
|
+
return [];
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
185
|
+
mockOpenArchiveSequelize.mockReturnValue({
|
|
186
|
+
query: mockQuery,
|
|
187
|
+
close: mockClose,
|
|
188
|
+
} as any);
|
|
189
|
+
|
|
190
|
+
await queryArchive({
|
|
191
|
+
table: 'invoices',
|
|
192
|
+
customer_id: 'cus_123',
|
|
193
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
194
|
+
page: 1,
|
|
195
|
+
limit: 10,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('customer_id = :customerId'), expect.any(Object));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should apply customer_id filter with json_extract for meter_events', async () => {
|
|
202
|
+
const mockClose = jest.fn();
|
|
203
|
+
const mockQuery = jest.fn().mockImplementation((sql: string) => {
|
|
204
|
+
if (sql.includes('sqlite_master')) {
|
|
205
|
+
return [[{ name: 'meter_events' }]];
|
|
206
|
+
}
|
|
207
|
+
return [];
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
211
|
+
mockOpenArchiveSequelize.mockReturnValue({
|
|
212
|
+
query: mockQuery,
|
|
213
|
+
close: mockClose,
|
|
214
|
+
} as any);
|
|
215
|
+
|
|
216
|
+
await queryArchive({
|
|
217
|
+
table: 'meter_events',
|
|
218
|
+
customer_id: 'cus_123',
|
|
219
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
220
|
+
page: 1,
|
|
221
|
+
limit: 10,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
225
|
+
expect.stringContaining("json_extract(payload, '$.customer_id')"),
|
|
226
|
+
expect.any(Object)
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should paginate results correctly', async () => {
|
|
231
|
+
const mockClose = jest.fn();
|
|
232
|
+
const mockQuery = jest.fn().mockImplementation((sql: string) => {
|
|
233
|
+
if (sql.includes('sqlite_master')) {
|
|
234
|
+
return [[{ name: 'invoices' }]];
|
|
235
|
+
}
|
|
236
|
+
return Array.from({ length: 25 }, (_, i) => ({
|
|
237
|
+
id: String(i + 1),
|
|
238
|
+
created_at: new Date(Date.now() - i * 1000),
|
|
239
|
+
}));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
|
|
243
|
+
mockOpenArchiveSequelize.mockReturnValue({
|
|
244
|
+
query: mockQuery,
|
|
245
|
+
close: mockClose,
|
|
246
|
+
} as any);
|
|
247
|
+
mockArchiveMetadataFindAll.mockResolvedValue([
|
|
248
|
+
{
|
|
249
|
+
id: 'meta-1',
|
|
250
|
+
query_count: 0,
|
|
251
|
+
query_actor_ids: [],
|
|
252
|
+
update: jest.fn(),
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const result = await queryArchive({
|
|
257
|
+
table: 'invoices',
|
|
258
|
+
from: Math.floor(Date.now() / 1000) - 86400,
|
|
259
|
+
page: 2,
|
|
260
|
+
limit: 10,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result.data).toHaveLength(10);
|
|
264
|
+
expect(result.total).toBe(25);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|