Skip to content

Commit 793e1ba

Browse files
fix(analytics): make service error-resilient, fix failing tests (#3361)
* fix(billing): replace dot separator with underscore in counter keys requireQuota middleware and getUsage controller built counter keys with dots (resource.action) which are rejected by SAFE_KEY_RE in the usage repository, causing 422 errors on quota-checked endpoints. Switch to underscore separator to match the allowed pattern. Closes #3350 * fix(home): isolate test user to prevent duplicate key errors Clean up admin user in afterAll and use a unique email prefix (admin-home-health) to avoid E11000 collisions when running all test suites together via test:coverage. Closes #3348 * fix(analytics): make service error-resilient, fix 5 failing tests Wrap track/identify/groupIdentify in try/catch so the analytics service never throws — callers no longer need defensive try/catch wrappers. Update resilience tests to expect not.toThrow instead of toThrow. Closes #3340 * fix(docs): align ERRORS.md checklist format with actual ERRORS.md convention Use `[YYYY-MM-DD] <scope>: <wrong> -> <right>` format instead of the generic "symptom, root cause, fix" description. * fix(billing): update stale comment to match underscore separator
1 parent 50cd5d7 commit 793e1ba

9 files changed

Lines changed: 49 additions & 35 deletions

File tree

.claude/skills/feature/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ If an action affects another user:
9393
- [ ] Templates created for each email type
9494

9595
**Error documentation:**
96-
- [ ] If a non-obvious bug was fixed, document it in `ERRORS.md` (root of repo) with: symptom, root cause, fix
96+
- [ ] If a non-obvious bug was fixed, add a single-line entry to `ERRORS.md` (root of repo) using format: `[YYYY-MM-DD] <scope>: <wrong> -> <right>` (see existing examples in `ERRORS.md`)
9797

9898
### 8b. Elegance check
9999

lib/services/analytics.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ const init = async () => {
3737
*/
3838
const track = (distinctId, event, properties, groups) => {
3939
if (!client) return;
40-
client.capture({ distinctId, event, properties, groups });
40+
try {
41+
client.capture({ distinctId, event, properties, groups });
42+
} catch (_) { /* analytics must never break caller */ }
4143
};
4244

4345
/**
@@ -48,7 +50,9 @@ const track = (distinctId, event, properties, groups) => {
4850
*/
4951
const identify = (distinctId, properties) => {
5052
if (!client) return;
51-
client.identify({ distinctId, properties });
53+
try {
54+
client.identify({ distinctId, properties });
55+
} catch (_) { /* analytics must never break caller */ }
5256
};
5357

5458
/**
@@ -60,7 +64,9 @@ const identify = (distinctId, properties) => {
6064
*/
6165
const groupIdentify = (groupType, groupKey, properties) => {
6266
if (!client) return;
63-
client.groupIdentify({ groupType, groupKey, properties });
67+
try {
68+
client.groupIdentify({ groupType, groupKey, properties });
69+
} catch (_) { /* analytics must never break caller */ }
6470
};
6571

6672
/**

lib/services/tests/analytics.service.resilience.unit.tests.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,31 +41,31 @@ describe('Analytics service resilience tests:', () => {
4141
});
4242

4343
// ─────────────────────────────────────────────────────────────────────
44-
// Error propagation — synchronous SDK failures
44+
// Error resilience — synchronous SDK failures are swallowed
4545
// ─────────────────────────────────────────────────────────────────────
46-
describe('synchronous SDK error propagation:', () => {
47-
test('track should propagate when client.capture throws', () => {
46+
describe('synchronous SDK error resilience:', () => {
47+
test('track should not throw when client.capture throws', () => {
4848
mockPostHogInstance.capture.mockImplementation(() => {
4949
throw new Error('Capture error');
5050
});
5151

52-
expect(() => AnalyticsService.track('u1', 'evt')).toThrow('Capture error');
52+
expect(() => AnalyticsService.track('u1', 'evt')).not.toThrow();
5353
});
5454

55-
test('identify should propagate when client.identify throws', () => {
55+
test('identify should not throw when client.identify throws', () => {
5656
mockPostHogInstance.identify.mockImplementation(() => {
5757
throw new Error('Identify error');
5858
});
5959

60-
expect(() => AnalyticsService.identify('u1', {})).toThrow('Identify error');
60+
expect(() => AnalyticsService.identify('u1', {})).not.toThrow();
6161
});
6262

63-
test('groupIdentify should propagate when client.groupIdentify throws', () => {
63+
test('groupIdentify should not throw when client.groupIdentify throws', () => {
6464
mockPostHogInstance.groupIdentify.mockImplementation(() => {
6565
throw new Error('Group error');
6666
});
6767

68-
expect(() => AnalyticsService.groupIdentify('company', 'org', {})).toThrow('Group error');
68+
expect(() => AnalyticsService.groupIdentify('company', 'org', {})).not.toThrow();
6969
});
7070
});
7171

modules/billing/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Increment counters after successful operations:
4141
import BillingUsageService from '../billing/services/billing.usage.service.js';
4242

4343
// After creating a document
44-
await BillingUsageService.increment(organizationId, 'documents.create', 1);
44+
await BillingUsageService.increment(organizationId, 'documents_create', 1);
4545
```
4646

4747
### Usage Endpoint

modules/billing/controllers/billing.controller.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const getUsage = async (req, res) => {
7373
// Get usage counters (includes month field)
7474
const usage = await BillingUsageService.get(req.organization._id.toString());
7575

76-
// Flatten quotas config into { "resource.action": limit } format
76+
// Flatten quotas config into { "resource_action": limit } format
7777
// Normalize Infinity to null for JSON-safe serialization
7878
const quotas = config.billing?.quotas;
7979
let limits = {};
@@ -82,7 +82,7 @@ const getUsage = async (req, res) => {
8282
for (const resource of Object.keys(planQuotas)) {
8383
for (const action of Object.keys(planQuotas[resource])) {
8484
const rawLimit = planQuotas[resource][action];
85-
limits[`${resource}.${action}`] = Number.isFinite(rawLimit) ? rawLimit : null;
85+
limits[`${resource}_${action}`] = Number.isFinite(rawLimit) ? rawLimit : null;
8686
}
8787
}
8888
}

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function requireQuota(resource, action) {
5454

5555
// Check current usage
5656
const usage = await BillingUsageService.get(req.organization._id.toString());
57-
const counterKey = `${resource}.${action}`;
57+
const counterKey = `${resource}_${action}`;
5858
const current = usage.counters[counterKey] || 0;
5959

6060
if (current >= limit) {

modules/billing/tests/billing.quota.unit.tests.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('requireQuota middleware:', () => {
6969

7070
test('should allow request when under quota', async () => {
7171
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
72-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 1 } });
72+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 1 } });
7373

7474
await requireQuota('scraps', 'create')(req, res, next);
7575

@@ -79,7 +79,7 @@ describe('requireQuota middleware:', () => {
7979

8080
test('should return 429 when at quota limit', async () => {
8181
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
82-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
82+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } });
8383

8484
await requireQuota('scraps', 'create')(req, res, next);
8585

@@ -95,7 +95,7 @@ describe('requireQuota middleware:', () => {
9595

9696
test('should return 429 when over quota limit', async () => {
9797
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
98-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 5 } });
98+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 5 } });
9999

100100
await requireQuota('scraps', 'create')(req, res, next);
101101

@@ -105,7 +105,7 @@ describe('requireQuota middleware:', () => {
105105

106106
test('should treat missing subscription as free plan', async () => {
107107
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
108-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
108+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } });
109109

110110
await requireQuota('scraps', 'create')(req, res, next);
111111

@@ -121,7 +121,7 @@ describe('requireQuota middleware:', () => {
121121
'should treat %s subscription as free plan',
122122
async (status) => {
123123
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status });
124-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
124+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } });
125125

126126
await requireQuota('scraps', 'create')(req, res, next);
127127

@@ -145,7 +145,7 @@ describe('requireQuota middleware:', () => {
145145

146146
test('should return correct error payload with upgradeUrl', async () => {
147147
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
148-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.execute': 100 } });
148+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_execute': 100 } });
149149

150150
await requireQuota('scraps', 'execute')(req, res, next);
151151

@@ -178,7 +178,7 @@ describe('requireQuota middleware:', () => {
178178

179179
test('should use subscription plan when status is trialing', async () => {
180180
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'trialing' });
181-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 15 } });
181+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 15 } });
182182

183183
await requireQuota('scraps', 'create')(req, res, next);
184184

modules/billing/tests/billing.usage.endpoint.unit.tests.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('Billing usage endpoint unit tests:', () => {
6363

6464
test('should return usage and limits for active subscription', async () => {
6565
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status: 'active' });
66-
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents.create': 5, 'requests.execute': 42 } });
66+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents_create': 5, 'requests_execute': 42 } });
6767

6868
const req = { organization: { _id: orgId } };
6969
await billingController.getUsage(req, res);
@@ -74,8 +74,8 @@ describe('Billing usage endpoint unit tests:', () => {
7474
message: 'billing usage',
7575
data: expect.objectContaining({
7676
plan: 'starter',
77-
usage: { 'documents.create': 5, 'requests.execute': 42 },
78-
limits: { 'documents.create': 20, 'requests.execute': 2000 },
77+
usage: { 'documents_create': 5, 'requests_execute': 42 },
78+
limits: { 'documents_create': 20, 'requests_execute': 2000 },
7979
}),
8080
}));
8181
});
@@ -92,7 +92,7 @@ describe('Billing usage endpoint unit tests:', () => {
9292
type: 'success',
9393
data: expect.objectContaining({
9494
plan: 'free',
95-
limits: { 'documents.create': 5, 'requests.execute': 100 },
95+
limits: { 'documents_create': 5, 'requests_execute': 100 },
9696
}),
9797
}));
9898
});
@@ -106,7 +106,7 @@ describe('Billing usage endpoint unit tests:', () => {
106106
'paused',
107107
])('should return free plan when subscription status is %s', async (status) => {
108108
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status });
109-
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents.create': 2 } });
109+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents_create': 2 } });
110110

111111
const req = { organization: { _id: orgId } };
112112
await billingController.getUsage(req, res);
@@ -115,7 +115,7 @@ describe('Billing usage endpoint unit tests:', () => {
115115
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
116116
data: expect.objectContaining({
117117
plan: 'free',
118-
limits: { 'documents.create': 5, 'requests.execute': 100 },
118+
limits: { 'documents_create': 5, 'requests_execute': 100 },
119119
}),
120120
}));
121121
});
@@ -131,7 +131,7 @@ describe('Billing usage endpoint unit tests:', () => {
131131
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
132132
data: expect.objectContaining({
133133
plan: 'starter',
134-
limits: { 'documents.create': 20, 'requests.execute': 2000 },
134+
limits: { 'documents_create': 20, 'requests_execute': 2000 },
135135
}),
136136
}));
137137
});
@@ -163,7 +163,7 @@ describe('Billing usage endpoint unit tests:', () => {
163163
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
164164
data: expect.objectContaining({
165165
plan: 'pro',
166-
limits: { 'documents.create': null, 'requests.execute': null },
166+
limits: { 'documents_create': null, 'requests_execute': null },
167167
}),
168168
}));
169169
});

modules/home/tests/home.integration.tests.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('Home integration tests:', () => {
1818
let agent;
1919
let HomeService;
2020
let adminToken;
21+
let adminUser;
2122
let originalOrganizationsEnabled;
2223

2324
// init
@@ -41,15 +42,16 @@ describe('Home integration tests:', () => {
4142

4243
// Create admin user and sign JWT for health endpoint test
4344
const User = mongoose.model('User');
44-
const admin = await User.create({
45+
await User.deleteOne({ email: 'admin-home-health@test.com' });
46+
adminUser = await User.create({
4547
firstName: 'Admin',
4648
lastName: 'Health',
47-
email: 'admin-health@test.com',
49+
email: 'admin-home-health@test.com',
4850
password: 'W@os.jsI$Aw3$0m3',
4951
provider: 'local',
5052
roles: ['admin'],
5153
});
52-
adminToken = jwt.sign({ userId: admin.id }, config.jwt.secret, { expiresIn: config.jwt.expiresIn });
54+
adminToken = jwt.sign({ userId: adminUser.id }, config.jwt.secret, { expiresIn: config.jwt.expiresIn });
5355
} catch (err) {
5456
console.log(err);
5557
expect(err).toBeFalsy();
@@ -204,10 +206,16 @@ describe('Home integration tests:', () => {
204206
});
205207
});
206208

207-
// Mongoose disconnect
209+
// Cleanup and mongoose disconnect
208210
afterAll(async () => {
209211
jest.restoreAllMocks();
210212
config.organizations.enabled = originalOrganizationsEnabled;
213+
try {
214+
if (adminUser) {
215+
const User = mongoose.model('User');
216+
await User.deleteOne({ _id: adminUser._id });
217+
}
218+
} catch (_) { /* cleanup – ignore errors */ }
211219
try {
212220
await mongooseService.disconnect();
213221
} catch (err) {

0 commit comments

Comments
 (0)