@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.
Files changed (60) hide show
  1. package/dist/entitlements/client.d.ts.map +1 -1
  2. package/dist/entitlements/client.js +17 -1
  3. package/dist/entitlements/client.js.map +1 -1
  4. package/dist/entitlements/index.d.ts +3 -2
  5. package/dist/entitlements/index.d.ts.map +1 -1
  6. package/dist/entitlements/index.js +5 -4
  7. package/dist/entitlements/index.js.map +1 -1
  8. package/dist/entitlements/middleware.d.ts +14 -3
  9. package/dist/entitlements/middleware.d.ts.map +1 -1
  10. package/dist/entitlements/middleware.js +32 -26
  11. package/dist/entitlements/middleware.js.map +1 -1
  12. package/dist/entitlements/reconcile.d.ts +74 -0
  13. package/dist/entitlements/reconcile.d.ts.map +1 -0
  14. package/dist/entitlements/reconcile.js +297 -0
  15. package/dist/entitlements/reconcile.js.map +1 -0
  16. package/dist/entitlements/types.d.ts +0 -8
  17. package/dist/entitlements/types.d.ts.map +1 -1
  18. package/dist/entitlements/usage.d.ts +1 -1
  19. package/dist/entitlements/usage.d.ts.map +1 -1
  20. package/dist/entitlements/usage.js +11 -20
  21. package/dist/entitlements/usage.js.map +1 -1
  22. package/dist/groups/client.d.ts +27 -0
  23. package/dist/groups/client.d.ts.map +1 -0
  24. package/dist/groups/client.js +153 -0
  25. package/dist/groups/client.js.map +1 -0
  26. package/dist/groups/index.d.ts +5 -0
  27. package/dist/groups/index.d.ts.map +1 -0
  28. package/dist/groups/index.js +10 -0
  29. package/dist/groups/index.js.map +1 -0
  30. package/dist/groups/subject.d.ts +30 -0
  31. package/dist/groups/subject.d.ts.map +1 -0
  32. package/dist/groups/subject.js +52 -0
  33. package/dist/groups/subject.js.map +1 -0
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/entitlements/client.test.ts +30 -0
  40. package/src/entitlements/client.ts +18 -1
  41. package/src/entitlements/index.ts +4 -2
  42. package/src/entitlements/middleware.test.ts +47 -30
  43. package/src/entitlements/middleware.ts +40 -26
  44. package/src/entitlements/reconcile.test.ts +333 -0
  45. package/src/entitlements/reconcile.ts +384 -0
  46. package/src/entitlements/types.ts +0 -9
  47. package/src/entitlements/usage.test.ts +27 -31
  48. package/src/entitlements/usage.ts +12 -22
  49. package/src/groups/client.test.ts +215 -0
  50. package/src/groups/client.ts +182 -0
  51. package/src/groups/index.ts +5 -0
  52. package/src/groups/subject.test.ts +85 -0
  53. package/src/groups/subject.ts +50 -0
  54. package/src/index.ts +3 -0
  55. package/dist/entitlements/mode.d.ts +0 -10
  56. package/dist/entitlements/mode.d.ts.map +0 -1
  57. package/dist/entitlements/mode.js +0 -32
  58. package/dist/entitlements/mode.js.map +0 -1
  59. package/src/entitlements/mode.test.ts +0 -50
  60. 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, ENTITLEMENTS_MODE: 'enforce' };
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 instead of blocking in observe mode', async () => {
93
- process.env.ENTITLEMENTS_MODE = 'observe';
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(next).toHaveBeenCalledWith();
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('never truncates history outside enforce mode', async () => {
175
- process.env.ENTITLEMENTS_MODE = 'observe';
151
+ it('still applies the floor when the limits are a stale fallback', async () => {
152
+ mockGetEntitlements.mockResolvedValue(entitlements({ stale: true }));
176
153
 
177
- expect(await retentionFloor('user@example.com', now)).toBeNull();
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
- if (mode === 'observe') {
61
- logger.info('entitlement.would_block', {
62
- event: 'entitlement.would_block',
63
- feature,
64
- subjectId: entitlements.email,
65
- planCode: entitlements.planCode,
66
- route: req.originalUrl,
67
- stale: entitlements.stale === true
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 user may read, or `null` for unlimited history.
112
+ * The oldest timestamp a caller may read, or `null` for unlimited history.
122
113
  *
123
- * Meant to be dropped straight into an aggregation `$match` — this is what
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(email: string, now: Date = new Date()): Promise<Date | null> {
129
- if (getEntitlementsMode() !== 'enforce') return null;
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 { limits } = await getEntitlements(email);
132
- const days = limits.retentionDays;
133
- if (days === UNLIMITED || days < 0) return null;
142
+ const floors = await Promise.all(
143
+ emails.map(async email => floorFor((await getEntitlements(email)).limits.retentionDays))
144
+ );
134
145
 
135
- return new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
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
+ });