@tumbaland/backend-core 1.25.0 → 1.27.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/dist/entitlements/client.d.ts.map +1 -1
- package/dist/entitlements/client.js +17 -1
- package/dist/entitlements/client.js.map +1 -1
- package/dist/entitlements/index.d.ts +3 -2
- package/dist/entitlements/index.d.ts.map +1 -1
- package/dist/entitlements/index.js +5 -4
- package/dist/entitlements/index.js.map +1 -1
- package/dist/entitlements/middleware.d.ts +14 -3
- package/dist/entitlements/middleware.d.ts.map +1 -1
- package/dist/entitlements/middleware.js +32 -26
- package/dist/entitlements/middleware.js.map +1 -1
- package/dist/entitlements/reconcile.d.ts +74 -0
- package/dist/entitlements/reconcile.d.ts.map +1 -0
- package/dist/entitlements/reconcile.js +297 -0
- package/dist/entitlements/reconcile.js.map +1 -0
- package/dist/entitlements/types.d.ts +0 -8
- package/dist/entitlements/types.d.ts.map +1 -1
- package/dist/entitlements/usage.d.ts +1 -1
- package/dist/entitlements/usage.d.ts.map +1 -1
- package/dist/entitlements/usage.js +11 -20
- package/dist/entitlements/usage.js.map +1 -1
- package/dist/groups/client.d.ts +27 -0
- package/dist/groups/client.d.ts.map +1 -0
- package/dist/groups/client.js +153 -0
- package/dist/groups/client.js.map +1 -0
- package/dist/groups/index.d.ts +5 -0
- package/dist/groups/index.d.ts.map +1 -0
- package/dist/groups/index.js +10 -0
- package/dist/groups/index.js.map +1 -0
- package/dist/groups/subject.d.ts +30 -0
- package/dist/groups/subject.d.ts.map +1 -0
- package/dist/groups/subject.js +52 -0
- package/dist/groups/subject.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/entitlements/client.test.ts +30 -0
- package/src/entitlements/client.ts +18 -1
- package/src/entitlements/index.ts +4 -2
- package/src/entitlements/middleware.test.ts +47 -30
- package/src/entitlements/middleware.ts +40 -26
- package/src/entitlements/reconcile.test.ts +333 -0
- package/src/entitlements/reconcile.ts +384 -0
- package/src/entitlements/types.ts +0 -9
- package/src/entitlements/usage.test.ts +27 -31
- package/src/entitlements/usage.ts +12 -22
- package/src/groups/client.test.ts +215 -0
- package/src/groups/client.ts +182 -0
- package/src/groups/index.ts +5 -0
- package/src/groups/subject.test.ts +85 -0
- package/src/groups/subject.ts +50 -0
- package/src/index.ts +3 -0
- package/dist/entitlements/mode.d.ts +0 -10
- package/dist/entitlements/mode.d.ts.map +0 -1
- package/dist/entitlements/mode.js +0 -32
- package/dist/entitlements/mode.js.map +0 -1
- package/src/entitlements/mode.test.ts +0 -50
- package/src/entitlements/mode.ts +0 -28
|
@@ -35,7 +35,7 @@ function mockReq(overrides: Partial<Request> = {}): Request {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
beforeEach(() => {
|
|
38
|
-
process.env = { ...ORIGINAL_ENV
|
|
38
|
+
process.env = { ...ORIGINAL_ENV };
|
|
39
39
|
jest.clearAllMocks();
|
|
40
40
|
mockGetEntitlements.mockResolvedValue(entitlements());
|
|
41
41
|
});
|
|
@@ -89,24 +89,10 @@ describe('requireEntitlement', () => {
|
|
|
89
89
|
);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it('logs
|
|
93
|
-
|
|
94
|
-
const next = jest.fn();
|
|
95
|
-
|
|
96
|
-
await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
|
|
92
|
+
it('logs the denial alongside throwing it', async () => {
|
|
93
|
+
await requireEntitlement('cleanExport')(mockReq(), {} as Response, jest.fn());
|
|
97
94
|
|
|
98
|
-
expect(
|
|
99
|
-
expect(logger.info).toHaveBeenCalledWith('entitlement.would_block', expect.objectContaining({ feature: 'cleanExport' }));
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('does not even look entitlements up when the mode is off', async () => {
|
|
103
|
-
process.env.ENTITLEMENTS_MODE = 'off';
|
|
104
|
-
const next = jest.fn();
|
|
105
|
-
|
|
106
|
-
await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
|
|
107
|
-
|
|
108
|
-
expect(next).toHaveBeenCalledWith();
|
|
109
|
-
expect(mockGetEntitlements).not.toHaveBeenCalled();
|
|
95
|
+
expect(logger.info).toHaveBeenCalledWith('entitlement.blocked', expect.objectContaining({ feature: 'cleanExport' }));
|
|
110
96
|
});
|
|
111
97
|
});
|
|
112
98
|
|
|
@@ -143,15 +129,6 @@ describe('requireQuota', () => {
|
|
|
143
129
|
expect(next).toHaveBeenCalledWith(denial);
|
|
144
130
|
});
|
|
145
131
|
|
|
146
|
-
it('is inert when the mode is off', async () => {
|
|
147
|
-
process.env.ENTITLEMENTS_MODE = 'off';
|
|
148
|
-
const next = jest.fn();
|
|
149
|
-
|
|
150
|
-
await requireQuota('storageBytes')(mockReq(), {} as Response, next);
|
|
151
|
-
|
|
152
|
-
expect(mockCheckQuota).not.toHaveBeenCalled();
|
|
153
|
-
expect(next).toHaveBeenCalledWith();
|
|
154
|
-
});
|
|
155
132
|
});
|
|
156
133
|
|
|
157
134
|
describe('retentionFloor', () => {
|
|
@@ -171,9 +148,49 @@ describe('retentionFloor', () => {
|
|
|
171
148
|
expect(await retentionFloor('user@example.com', now)).toBeNull();
|
|
172
149
|
});
|
|
173
150
|
|
|
174
|
-
it('
|
|
175
|
-
|
|
151
|
+
it('still applies the floor when the limits are a stale fallback', async () => {
|
|
152
|
+
mockGetEntitlements.mockResolvedValue(entitlements({ stale: true }));
|
|
176
153
|
|
|
177
|
-
|
|
154
|
+
// Fail closed, same as every other gate: an outage clips history to the
|
|
155
|
+
// free tier for a minute rather than handing the full archive out.
|
|
156
|
+
expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('retentionFloor across several subjects', () => {
|
|
161
|
+
const now = new Date('2026-08-09T00:00:00.000Z');
|
|
162
|
+
|
|
163
|
+
it('takes the most generous floor, so a paid group lifts it for every member', () => {
|
|
164
|
+
mockGetEntitlements.mockImplementation(async (email: string) =>
|
|
165
|
+
email === 'owner@example.com'
|
|
166
|
+
? entitlements({ planCode: 'family', limits: normalizeLimits({ retentionDays: UNLIMITED }, 'family') })
|
|
167
|
+
: entitlements()
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// A free member reading a Family group's data must not be clipped to 90
|
|
171
|
+
// days of a household's history the household paid to keep.
|
|
172
|
+
return expect(retentionFloor(['member@example.com', 'owner@example.com'], now)).resolves.toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('picks the floor that reaches furthest back when none is unlimited', async () => {
|
|
176
|
+
mockGetEntitlements.mockImplementation(async (email: string) =>
|
|
177
|
+
entitlements({ limits: normalizeLimits({ retentionDays: email === 'owner@example.com' ? 365 : 90 }, 'free') })
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(await retentionFloor(['member@example.com', 'owner@example.com'], now)).toEqual(
|
|
181
|
+
new Date('2025-08-09T00:00:00.000Z')
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('still accepts a single email', async () => {
|
|
186
|
+
mockGetEntitlements.mockResolvedValue(entitlements());
|
|
187
|
+
|
|
188
|
+
expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('falls back to the free floor when no subject can be identified', async () => {
|
|
192
|
+
// An unidentifiable caller must never resolve to unlimited history.
|
|
193
|
+
expect(await retentionFloor([], now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
|
|
194
|
+
expect(mockGetEntitlements).not.toHaveBeenCalled();
|
|
178
195
|
});
|
|
179
196
|
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Request, RequestHandler } from 'express';
|
|
2
2
|
import logger from '../logging/logger';
|
|
3
3
|
import { PaymentRequiredError, UnauthorizedError } from '../errors/HttpError';
|
|
4
|
-
import { FeatureKey, MeterKey, UNLIMITED } from './definitions';
|
|
4
|
+
import { FALLBACK_PLAN, FeatureKey, MeterKey, UNLIMITED, resolveLimits } from './definitions';
|
|
5
5
|
import { getEntitlements } from './client';
|
|
6
6
|
import { checkQuota } from './usage';
|
|
7
|
-
import { getEntitlementsMode } from './mode';
|
|
8
7
|
import { Entitlements } from './types';
|
|
9
8
|
|
|
10
9
|
declare global {
|
|
@@ -51,23 +50,17 @@ export async function loadEntitlements(req: Request, resolve: SubjectResolver =
|
|
|
51
50
|
export function requireEntitlement(feature: FeatureKey, options: EntitlementOptions = {}): RequestHandler {
|
|
52
51
|
return async (req, _res, next) => {
|
|
53
52
|
try {
|
|
54
|
-
const mode = getEntitlementsMode();
|
|
55
|
-
if (mode === 'off') return next();
|
|
56
|
-
|
|
57
53
|
const entitlements = await loadEntitlements(req, options.subject);
|
|
58
54
|
if (entitlements.limits.features[feature]) return next();
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
69
|
-
return next();
|
|
70
|
-
}
|
|
56
|
+
logger.info('entitlement.blocked', {
|
|
57
|
+
event: 'entitlement.blocked',
|
|
58
|
+
feature,
|
|
59
|
+
subjectId: entitlements.email,
|
|
60
|
+
planCode: entitlements.planCode,
|
|
61
|
+
route: req.originalUrl,
|
|
62
|
+
stale: entitlements.stale === true
|
|
63
|
+
});
|
|
71
64
|
|
|
72
65
|
throw new PaymentRequiredError(`Your plan does not include ${feature}`, {
|
|
73
66
|
code: 'FEATURE_NOT_IN_PLAN',
|
|
@@ -98,8 +91,6 @@ export interface QuotaOptions extends EntitlementOptions {
|
|
|
98
91
|
export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): RequestHandler {
|
|
99
92
|
return async (req, _res, next) => {
|
|
100
93
|
try {
|
|
101
|
-
if (getEntitlementsMode() === 'off') return next();
|
|
102
|
-
|
|
103
94
|
const entitlements = await loadEntitlements(req, options.subject);
|
|
104
95
|
const amount = typeof options.amount === 'function' ? options.amount(req) : (options.amount ?? 1);
|
|
105
96
|
|
|
@@ -118,19 +109,42 @@ export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): Reque
|
|
|
118
109
|
}
|
|
119
110
|
|
|
120
111
|
/**
|
|
121
|
-
* The oldest timestamp a
|
|
112
|
+
* The oldest timestamp a caller may read, or `null` for unlimited history.
|
|
122
113
|
*
|
|
123
|
-
* Meant to be dropped straight into
|
|
114
|
+
* Meant to be dropped straight into a `$match` or a `find` filter — this is what
|
|
124
115
|
* gates the portfolio planner, since the planner runs entirely client-side and
|
|
125
116
|
* any `<PaywallGate>` around it is bypassable from devtools. Truncating the
|
|
126
117
|
* price history it depends on is not.
|
|
118
|
+
*
|
|
119
|
+
* **Several subjects resolve to the most generous floor.** Retention is not a
|
|
120
|
+
* counter, so unlike a quota it has no single payer: data held in a group is
|
|
121
|
+
* read by every member, and if it resolved to the *caller's* plan alone, a free
|
|
122
|
+
* member of a paid Family group would see ninety days of a household's history
|
|
123
|
+
* that the household paid to keep. Pass the caller together with the owners of
|
|
124
|
+
* the groups being read, and the group's plan lifts the floor for everyone in
|
|
125
|
+
* it — which is what "shared across a household" was sold as.
|
|
126
|
+
*
|
|
127
|
+
* An empty list resolves to the free plan's floor rather than to `null`: no
|
|
128
|
+
* identifiable subject must never mean unlimited history.
|
|
127
129
|
*/
|
|
128
|
-
export async function retentionFloor(
|
|
129
|
-
|
|
130
|
+
export async function retentionFloor(
|
|
131
|
+
subject: string | readonly string[],
|
|
132
|
+
now: Date = new Date()
|
|
133
|
+
): Promise<Date | null> {
|
|
134
|
+
const emails = (Array.isArray(subject) ? subject : [subject as string]).filter(Boolean);
|
|
135
|
+
const floorFor = (days: number): Date | null =>
|
|
136
|
+
days === UNLIMITED || days < 0 ? null : new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
137
|
+
|
|
138
|
+
if (emails.length === 0) {
|
|
139
|
+
return floorFor(resolveLimits(FALLBACK_PLAN).retentionDays);
|
|
140
|
+
}
|
|
130
141
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
142
|
+
const floors = await Promise.all(
|
|
143
|
+
emails.map(async email => floorFor((await getEntitlements(email)).limits.retentionDays))
|
|
144
|
+
);
|
|
134
145
|
|
|
135
|
-
|
|
146
|
+
// `null` is unlimited, which beats every date; otherwise the earliest floor
|
|
147
|
+
// is the one that reaches furthest back.
|
|
148
|
+
if (floors.some(floor => floor === null)) return null;
|
|
149
|
+
return (floors as Date[]).reduce((a, b) => (a < b ? a : b));
|
|
136
150
|
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
jest.mock('../logging/logger', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
import mongoose from 'mongoose';
|
|
7
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
8
|
+
import logger from '../logging/logger';
|
|
9
|
+
import { UsageMeter } from './UsageMeter';
|
|
10
|
+
import { getUsage, setUsage } from './usage';
|
|
11
|
+
import { reconcileUsageMeters, runUsageReconciliation } from './reconcile';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Against a real Mongo, not mocks: the whole point of this module is the shape
|
|
15
|
+
* of the aggregations and the joins between four services' collections, and a
|
|
16
|
+
* mocked driver would assert nothing about either.
|
|
17
|
+
*/
|
|
18
|
+
let mongod: MongoMemoryServer;
|
|
19
|
+
|
|
20
|
+
const OWNER = 'owner@example.com';
|
|
21
|
+
const MEMBER = 'member@example.com';
|
|
22
|
+
const LONER = 'loner@example.com';
|
|
23
|
+
|
|
24
|
+
const OWNER_ID = '68987cf71078a6d4d52ba430';
|
|
25
|
+
const MEMBER_ID = '689880b91078a6d4d52ba43f';
|
|
26
|
+
const LONER_ID = '68b4e9fc05b3c0001a2286c3';
|
|
27
|
+
const GROUP_ID = '689a8219e15515e8f6539ad2';
|
|
28
|
+
|
|
29
|
+
const db = () => mongoose.connection.db!;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
mongod = await MongoMemoryServer.create();
|
|
33
|
+
await mongoose.connect(mongod.getUri());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await mongoose.disconnect();
|
|
38
|
+
await mongod.stop();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The shape the live database actually has: everything owned through one group. */
|
|
42
|
+
async function seed(
|
|
43
|
+
overrides: {
|
|
44
|
+
groups?: unknown[];
|
|
45
|
+
albums?: unknown[];
|
|
46
|
+
photos?: unknown[];
|
|
47
|
+
tickers?: unknown[];
|
|
48
|
+
} = {}
|
|
49
|
+
) {
|
|
50
|
+
await db()
|
|
51
|
+
.collection('users')
|
|
52
|
+
.insertMany([
|
|
53
|
+
{ _id: new mongoose.Types.ObjectId(OWNER_ID), email: 'Owner@Example.com' },
|
|
54
|
+
{ _id: new mongoose.Types.ObjectId(MEMBER_ID), email: MEMBER },
|
|
55
|
+
{ _id: new mongoose.Types.ObjectId(LONER_ID), email: LONER }
|
|
56
|
+
] as never);
|
|
57
|
+
|
|
58
|
+
const groups = overrides.groups ?? [
|
|
59
|
+
{
|
|
60
|
+
_id: new mongoose.Types.ObjectId(GROUP_ID),
|
|
61
|
+
name: 'Household',
|
|
62
|
+
createdBy: OWNER,
|
|
63
|
+
members: [
|
|
64
|
+
{ email: OWNER, role: 'owner' },
|
|
65
|
+
{ email: MEMBER, role: 'admin' }
|
|
66
|
+
],
|
|
67
|
+
deletedAt: null
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
if (groups.length) await db().collection('groups').insertMany(groups as never);
|
|
71
|
+
|
|
72
|
+
const albums = overrides.albums ?? [
|
|
73
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, totalSize: 300, name: 'Shared' },
|
|
74
|
+
{ _id: new mongoose.Types.ObjectId(), userId: MEMBER_ID, totalSize: 50, name: 'Personal' }
|
|
75
|
+
];
|
|
76
|
+
if (albums.length) await db().collection('albums').insertMany(albums as never);
|
|
77
|
+
|
|
78
|
+
const photos =
|
|
79
|
+
overrides.photos ??
|
|
80
|
+
(albums as { _id: mongoose.Types.ObjectId }[]).flatMap((album, i) => [
|
|
81
|
+
{ albumId: album._id, size: i === 0 ? 200 : 50 },
|
|
82
|
+
...(i === 0 ? [{ albumId: album._id, size: 100 }] : [])
|
|
83
|
+
]);
|
|
84
|
+
if ((photos as unknown[]).length) await db().collection('photos').insertMany(photos as never);
|
|
85
|
+
|
|
86
|
+
const tickers = overrides.tickers ?? [
|
|
87
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, ticker: 'VWCE.DEX', isActive: true },
|
|
88
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, ticker: 'VUSA.DEX', isActive: true }
|
|
89
|
+
];
|
|
90
|
+
if ((tickers as unknown[]).length) await db().collection('trackedtickers').insertMany(tickers as never);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
for (const name of ['users', 'groups', 'albums', 'photos', 'trackedtickers']) {
|
|
95
|
+
await db().collection(name).deleteMany({});
|
|
96
|
+
}
|
|
97
|
+
await UsageMeter.deleteMany({});
|
|
98
|
+
await UsageMeter.syncIndexes();
|
|
99
|
+
jest.clearAllMocks();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('reconcileUsageMeters', () => {
|
|
103
|
+
it('bills a group album to the owner and a personal album to its own user', async () => {
|
|
104
|
+
await seed();
|
|
105
|
+
|
|
106
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
107
|
+
|
|
108
|
+
// 200 + 100 in the shared album, all to the owner; the member keeps only
|
|
109
|
+
// their own 50, rather than the shared bytes landing on their free tier.
|
|
110
|
+
expect(report.expected[OWNER].storageBytes).toBe(300);
|
|
111
|
+
expect(report.expected[MEMBER].storageBytes).toBe(50);
|
|
112
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
113
|
+
expect(await getUsage(MEMBER, 'storageBytes')).toBe(50);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('joins userId to email through the users collection, matching on lowercase', async () => {
|
|
117
|
+
await seed();
|
|
118
|
+
|
|
119
|
+
await reconcileUsageMeters({ apply: true });
|
|
120
|
+
|
|
121
|
+
// The seeded user has a capitalised address; meters are keyed lowercase, so
|
|
122
|
+
// a missed normalization would open a second counter instead of filling one.
|
|
123
|
+
expect(await getUsage('owner@example.com', 'storageBytes')).toBe(300);
|
|
124
|
+
expect(await UsageMeter.countDocuments({ meter: 'storageBytes' })).toBe(2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('counts distinct active symbols, not rows', async () => {
|
|
128
|
+
await seed({
|
|
129
|
+
tickers: [
|
|
130
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, groupId: GROUP_ID, ticker: 'VWCE.DEX', isActive: true },
|
|
131
|
+
// Same symbol held personally as well — one Alpha Vantage call covers both.
|
|
132
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, ticker: 'vwce.dex', isActive: true },
|
|
133
|
+
{ _id: new mongoose.Types.ObjectId(), userId: OWNER_ID, ticker: 'DEAD.DEX', isActive: false }
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await reconcileUsageMeters({ apply: true });
|
|
138
|
+
|
|
139
|
+
expect(await getUsage(OWNER, 'trackedTickers')).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('counts seats as distinct people in the groups a subject owns', async () => {
|
|
143
|
+
await seed();
|
|
144
|
+
|
|
145
|
+
await reconcileUsageMeters({ apply: true });
|
|
146
|
+
|
|
147
|
+
expect(await getUsage(OWNER, 'seats')).toBe(2);
|
|
148
|
+
// A member is not charged for a group somebody else owns.
|
|
149
|
+
expect(await getUsage(MEMBER, 'seats')).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('keeps billing a soft-deleted group storage and tickers, but not seats', async () => {
|
|
153
|
+
await seed({
|
|
154
|
+
groups: [
|
|
155
|
+
{
|
|
156
|
+
_id: new mongoose.Types.ObjectId(GROUP_ID),
|
|
157
|
+
name: 'Household',
|
|
158
|
+
createdBy: OWNER,
|
|
159
|
+
members: [
|
|
160
|
+
{ email: OWNER, role: 'owner' },
|
|
161
|
+
{ email: MEMBER, role: 'admin' }
|
|
162
|
+
],
|
|
163
|
+
deletedAt: new Date('2026-01-01')
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await reconcileUsageMeters({ apply: true });
|
|
169
|
+
|
|
170
|
+
// The photos are still in MinIO and the tickers are still fetched daily;
|
|
171
|
+
// the seats grant nobody access to anything.
|
|
172
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
173
|
+
expect(await getUsage(OWNER, 'trackedTickers')).toBe(2);
|
|
174
|
+
expect(await getUsage(OWNER, 'seats')).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('prefers the owner role over a stale createdBy', async () => {
|
|
178
|
+
await seed({
|
|
179
|
+
groups: [
|
|
180
|
+
{
|
|
181
|
+
_id: new mongoose.Types.ObjectId(GROUP_ID),
|
|
182
|
+
name: 'Household',
|
|
183
|
+
createdBy: LONER,
|
|
184
|
+
members: [
|
|
185
|
+
{ email: OWNER, role: 'owner' },
|
|
186
|
+
{ email: MEMBER, role: 'admin' }
|
|
187
|
+
],
|
|
188
|
+
deletedAt: null
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await reconcileUsageMeters({ apply: true });
|
|
194
|
+
|
|
195
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
196
|
+
expect(await getUsage(LONER, 'storageBytes')).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('writes nothing without apply', async () => {
|
|
200
|
+
await seed();
|
|
201
|
+
|
|
202
|
+
const report = await reconcileUsageMeters();
|
|
203
|
+
|
|
204
|
+
expect(report.changes.length).toBeGreaterThan(0);
|
|
205
|
+
expect(report.written).toBe(0);
|
|
206
|
+
expect(await UsageMeter.countDocuments({})).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('is idempotent — a second run in a row changes nothing', async () => {
|
|
210
|
+
await seed();
|
|
211
|
+
|
|
212
|
+
await reconcileUsageMeters({ apply: true });
|
|
213
|
+
const second = await reconcileUsageMeters({ apply: true });
|
|
214
|
+
|
|
215
|
+
expect(second.changes).toEqual([]);
|
|
216
|
+
expect(second.written).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('repairs a leaked increment, which is the whole reason it is scheduled', async () => {
|
|
220
|
+
await seed();
|
|
221
|
+
// A crash between writing the photo and updating the meter, or a release
|
|
222
|
+
// that never ran: the counter says more than the photos do.
|
|
223
|
+
await setUsage(OWNER, 'storageBytes', 999999);
|
|
224
|
+
|
|
225
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
226
|
+
|
|
227
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
228
|
+
expect(report.changes).toContainEqual({ subjectId: OWNER, meter: 'storageBytes', from: 999999, to: 300 });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('corrects an existing counter down to zero but never creates a zero row', async () => {
|
|
232
|
+
await seed({ albums: [], photos: [], tickers: [] });
|
|
233
|
+
await setUsage(OWNER, 'storageBytes', 500);
|
|
234
|
+
|
|
235
|
+
await reconcileUsageMeters({ apply: true });
|
|
236
|
+
|
|
237
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(0);
|
|
238
|
+
// The other users had no counter and no usage — nothing was invented.
|
|
239
|
+
expect(await UsageMeter.countDocuments({ subjectId: LONER })).toBe(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('reports meters for unknown subjects instead of deleting them', async () => {
|
|
243
|
+
await seed();
|
|
244
|
+
await setUsage('ghost@example.com', 'storageBytes', 42);
|
|
245
|
+
|
|
246
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
247
|
+
|
|
248
|
+
expect(report.orphanedMeters).toEqual([{ subjectId: 'ghost@example.com', meter: 'storageBytes', count: 42 }]);
|
|
249
|
+
expect(await getUsage('ghost@example.com', 'storageBytes')).toBe(42);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('scopes to one subject when asked, leaving everyone else untouched', async () => {
|
|
253
|
+
await seed();
|
|
254
|
+
await setUsage(MEMBER, 'storageBytes', 777);
|
|
255
|
+
|
|
256
|
+
await reconcileUsageMeters({ apply: true, subject: 'Owner@Example.com' });
|
|
257
|
+
|
|
258
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
259
|
+
expect(await getUsage(MEMBER, 'storageBytes')).toBe(777);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('never touches aiJobs, which has no source data to recompute from', async () => {
|
|
263
|
+
await seed();
|
|
264
|
+
await setUsage(OWNER, 'aiJobs', 17);
|
|
265
|
+
|
|
266
|
+
await reconcileUsageMeters({ apply: true });
|
|
267
|
+
|
|
268
|
+
expect(await getUsage(OWNER, 'aiJobs')).toBe(17);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('bills an album in a vanished group to its uploader rather than losing the bytes', async () => {
|
|
272
|
+
await seed({ groups: [] });
|
|
273
|
+
|
|
274
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
275
|
+
|
|
276
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
277
|
+
expect(report.warnings.some(w => w.includes('references missing group'))).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('warns when a denormalized album counter disagrees, without rewriting it', async () => {
|
|
281
|
+
const albumId = new mongoose.Types.ObjectId();
|
|
282
|
+
await seed({
|
|
283
|
+
albums: [{ _id: albumId, userId: MEMBER_ID, totalSize: 9999, name: 'Drifted' }],
|
|
284
|
+
photos: [{ albumId, size: 50 }],
|
|
285
|
+
tickers: []
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
289
|
+
|
|
290
|
+
expect(await getUsage(MEMBER, 'storageBytes')).toBe(50);
|
|
291
|
+
expect(report.warnings.some(w => w.includes('but photos sum to 50'))).toBe(true);
|
|
292
|
+
const album = await db().collection('albums').findOne({ _id: albumId });
|
|
293
|
+
expect(album?.totalSize).toBe(9999);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('runUsageReconciliation', () => {
|
|
298
|
+
it('applies, and logs a correction at warn so a persistent leak is visible', async () => {
|
|
299
|
+
await seed();
|
|
300
|
+
// Settle first, so the only change the scheduled run sees is the drift.
|
|
301
|
+
await reconcileUsageMeters({ apply: true });
|
|
302
|
+
await setUsage(OWNER, 'storageBytes', 999999);
|
|
303
|
+
jest.clearAllMocks();
|
|
304
|
+
|
|
305
|
+
const report = await runUsageReconciliation();
|
|
306
|
+
|
|
307
|
+
expect(report?.written).toBe(1);
|
|
308
|
+
expect(await getUsage(OWNER, 'storageBytes')).toBe(300);
|
|
309
|
+
expect(logger.warn).toHaveBeenCalledWith('entitlement.reconciled', expect.objectContaining({ corrected: 1 }));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('logs at info when nothing drifted', async () => {
|
|
313
|
+
await seed();
|
|
314
|
+
await reconcileUsageMeters({ apply: true });
|
|
315
|
+
jest.clearAllMocks();
|
|
316
|
+
|
|
317
|
+
await runUsageReconciliation();
|
|
318
|
+
|
|
319
|
+
expect(logger.info).toHaveBeenCalledWith('entitlement.reconciled', expect.objectContaining({ corrected: 0 }));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('swallows a failure rather than taking the process down from a cron callback', async () => {
|
|
323
|
+
const spy = jest.spyOn(UsageMeter, 'find').mockImplementationOnce(() => {
|
|
324
|
+
throw new Error('mongo is gone');
|
|
325
|
+
});
|
|
326
|
+
await seed();
|
|
327
|
+
|
|
328
|
+
await expect(runUsageReconciliation()).resolves.toBeNull();
|
|
329
|
+
expect(logger.error).toHaveBeenCalledWith('Usage meter reconciliation failed', expect.anything());
|
|
330
|
+
|
|
331
|
+
spy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|