Skip to content

Commit 0b2bdfa

Browse files
authored
Merge pull request #982 from mnfst/full-coverage
test: achieve 100% line coverage across all packages
2 parents 0d361d5 + aafebfa commit 0b2bdfa

File tree

83 files changed

+4129
-60
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

83 files changed

+4129
-60
lines changed

.changeset/full-coverage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": patch
3+
---
4+
5+
Achieve 100% line coverage across backend, frontend, and plugin test suites

CLAUDE.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Manifest Development Guidelines
22

3-
Last updated: 2026-03-02
3+
Last updated: 2026-03-05
44

55
## IMPORTANT: Local Mode First
66

@@ -429,11 +429,21 @@ Codecov runs on every PR via the `codecov/patch` and `codecov/project` checks. C
429429
### Thresholds
430430

431431
- **Project coverage** (`codecov/project`): Must not drop more than **1%** below the base branch (`target: auto`, `threshold: 1%`).
432-
- **Patch coverage** (`codecov/patch`): New/changed lines must have at least **auto - 5%** coverage (`target: auto`, `threshold: 5%`). In practice, aim for **>90%** patch coverage.
432+
- **Patch coverage** (`codecov/patch`): New/changed lines must have at least **auto - 5%** coverage (`target: auto`, `threshold: 5%`).
433433

434-
### CRITICAL: Write Tests for New Code
434+
### CRITICAL: 100% Line Coverage Required
435435

436-
**Every new source file or modified function must have corresponding tests.** Codecov will fail the PR if changed lines are not covered. This applies to:
436+
**Every PR must maintain 100% line coverage across all three packages.** The codebase currently has full line coverage and every PR must preserve it. This means:
437+
438+
- All new source files must have corresponding tests with 100% line coverage
439+
- All modified functions must have tests covering every line, including error paths
440+
- **Patch coverage must be 100%** — no new uncovered lines allowed
441+
- Run coverage locally before creating a PR:
442+
- `cd packages/backend && npx jest --coverage`
443+
- `cd packages/frontend && npx vitest run --coverage`
444+
- `cd packages/openclaw-plugin && npx jest --coverage`
445+
446+
This applies to:
437447

438448
- New services, guards, controllers, or utilities in `packages/backend/src/`
439449
- New components or functions in `packages/frontend/src/`

packages/backend/src/auth/auth.instance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { VerifyEmailEmail } from '../notifications/emails/verify-email';
44
import { ResetPasswordEmail } from '../notifications/emails/reset-password';
55
import { sendEmail } from '../notifications/services/email-providers/send-email';
66
import { trackCloudEvent } from '../common/utils/product-telemetry';
7+
import { getLocalAuthSecret } from '../common/constants/local-mode.constants';
78

89
const isLocalMode = process.env['MANIFEST_MODE'] === 'local';
910
const port = process.env['PORT'] ?? '3001';

packages/backend/src/common/guards/api-key.guard.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ describe('ApiKeyGuard', () => {
132132
expect(mockUpdate).toHaveBeenCalled();
133133
});
134134

135+
it('returns true immediately when route is marked @Public()', async () => {
136+
(reflector.getAllAndOverride as jest.Mock).mockReturnValueOnce(true);
137+
const ctx = makeContext({});
138+
139+
const result = await guard.canActivate(ctx);
140+
expect(result).toBe(true);
141+
expect(mockFindOne).not.toHaveBeenCalled();
142+
});
143+
135144
it('skips API key validation when request.user is already set', async () => {
136145
const ctx = {
137146
switchToHttp: () => ({

packages/backend/src/common/services/cache-invalidation.service.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,24 @@ describe('CacheInvalidationService', () => {
136136
});
137137
});
138138

139+
describe('periodic cleanup timer', () => {
140+
it('should clear all tracked keys when cleanup interval fires', () => {
141+
service.onModuleInit();
142+
143+
service.trackKey('user-1', 'key-a');
144+
service.trackKey('user-2', 'key-b');
145+
146+
// Advance past the 60s cleanup interval
147+
jest.advanceTimersByTime(60_000);
148+
149+
// After cleanup, emitting should not trigger any deletions
150+
eventBus.emit('user-1');
151+
jest.advanceTimersByTime(1000);
152+
153+
expect(mockDel).not.toHaveBeenCalled();
154+
});
155+
});
156+
139157
describe('onModuleDestroy', () => {
140158
it('should unsubscribe from event bus on destroy', () => {
141159
service.onModuleInit();

packages/backend/src/common/utils/crypto.util.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,21 @@ describe('isEncrypted', () => {
118118
it('returns false when parts are not valid base64', () => {
119119
expect(isEncrypted('not!base64:not!base64:not!base64:not!base64')).toBe(false);
120120
});
121+
122+
it('returns false when Buffer.from throws (catch branch)', () => {
123+
const originalFrom = Buffer.from.bind(Buffer);
124+
const mockFrom = jest.fn().mockImplementation(
125+
(value: unknown, encoding?: string) => {
126+
if (encoding === 'base64') throw new Error('mocked');
127+
return originalFrom(value as string, encoding as BufferEncoding);
128+
},
129+
);
130+
Buffer.from = mockFrom as unknown as typeof Buffer.from;
131+
132+
try {
133+
expect(isEncrypted('a:b:c:d')).toBe(false);
134+
} finally {
135+
Buffer.from = originalFrom as unknown as typeof Buffer.from;
136+
}
137+
});
121138
});

packages/backend/src/common/utils/period.util.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ describe('computePeriodResetDate', () => {
106106
expect(reset.getUTCMonth()).toBe(expectedMonth);
107107
});
108108

109+
it('week on a Monday: daysUntilMonday falls back to 7', () => {
110+
// 2026-03-02 is a Monday (UTC day = 1)
111+
const monday = new Date(Date.UTC(2026, 2, 2, 12, 0, 0));
112+
jest.useFakeTimers();
113+
jest.setSystemTime(monday);
114+
115+
const result = computePeriodResetDate('week');
116+
const reset = new Date(result.replace(' ', 'T') + 'Z');
117+
118+
// Should be next Monday, exactly 7 days later
119+
expect(reset.getUTCDay()).toBe(1);
120+
expect(reset.getUTCDate()).toBe(monday.getUTCDate() + 7);
121+
122+
jest.useRealTimers();
123+
});
124+
109125
it('unknown period: defaults to hour-like behavior', () => {
110126
const result = computePeriodResetDate('unknown');
111127
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);

packages/backend/src/config/app.config.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,16 @@ describe('appConfig', () => {
6565
expect(config.throttleTtl).toBe(60000);
6666
expect(config.throttleLimit).toBe(100);
6767
});
68+
69+
it('defaults nodeEnv to development when NODE_ENV is not set', async () => {
70+
delete process.env['NODE_ENV'];
71+
const config = await loadConfig();
72+
expect(config.nodeEnv).toBe('development');
73+
});
74+
75+
it('reads NODE_ENV from env', async () => {
76+
process.env['NODE_ENV'] = 'production';
77+
const config = await loadConfig();
78+
expect(config.nodeEnv).toBe('production');
79+
});
6880
});

packages/backend/src/database/database-seeder.service.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ describe('DatabaseSeederService', () => {
8686
});
8787

8888
describe('onModuleInit', () => {
89+
it('should skip everything in local mode', async () => {
90+
process.env['MANIFEST_MODE'] = 'local';
91+
92+
await service.onModuleInit();
93+
94+
// Nothing should be called — early return on line 41
95+
expect(mockPricingRepo.upsert).not.toHaveBeenCalled();
96+
expect(mockApiKeyRepo.count).not.toHaveBeenCalled();
97+
expect(mockTenantRepo.count).not.toHaveBeenCalled();
98+
});
99+
89100
it('should run Better Auth migrations', async () => {
90101
const ctx = await auth.$context;
91102
await service.onModuleInit();
@@ -499,5 +510,31 @@ describe('DatabaseSeederService', () => {
499510

500511
expect(mockApiKeyRepo.insert).not.toHaveBeenCalled();
501512
});
513+
514+
it('should handle getAdminUserId query failure with Error and return null', async () => {
515+
// checkBetterAuthUser: user exists (skip signUpEmail)
516+
mockDataSource.query.mockResolvedValueOnce([{ id: 'x' }]);
517+
// getAdminUserId in seedApiKey: throws Error
518+
mockDataSource.query.mockRejectedValueOnce(new Error('connection lost'));
519+
mockApiKeyRepo.count.mockResolvedValue(0);
520+
521+
await service.onModuleInit();
522+
523+
// seedApiKey should skip insert because getAdminUserId returned null
524+
expect(mockApiKeyRepo.insert).not.toHaveBeenCalled();
525+
});
526+
527+
it('should handle getAdminUserId query failure with non-Error and return null', async () => {
528+
// checkBetterAuthUser: user exists (skip signUpEmail)
529+
mockDataSource.query.mockResolvedValueOnce([{ id: 'x' }]);
530+
// getAdminUserId in seedApiKey: throws non-Error value
531+
mockDataSource.query.mockRejectedValueOnce('string rejection');
532+
mockApiKeyRepo.count.mockResolvedValue(0);
533+
534+
await service.onModuleInit();
535+
536+
// seedApiKey should skip insert because getAdminUserId returned null
537+
expect(mockApiKeyRepo.insert).not.toHaveBeenCalled();
538+
});
502539
});
503540
});

packages/backend/src/database/local-bootstrap.service.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ describe('LocalBootstrapService', () => {
161161
expect(mockAgentKeyRepo.insert).not.toHaveBeenCalled();
162162
});
163163

164+
it('returns null when readFileSync throws (catches error in readApiKeyFromConfig)', async () => {
165+
// eslint-disable-next-line @typescript-eslint/no-require-imports
166+
const { existsSync, readFileSync } = require('fs');
167+
(existsSync as jest.Mock).mockReturnValue(true);
168+
(readFileSync as jest.Mock).mockImplementation(() => {
169+
throw new Error('EACCES: permission denied');
170+
});
171+
172+
await service.onModuleInit();
173+
174+
// readApiKeyFromConfig returns null on error, so no key registration
175+
expect(mockAgentKeyRepo.insert).not.toHaveBeenCalled();
176+
});
177+
164178
it('registers API key when config file exists with apiKey', async () => {
165179
// eslint-disable-next-line @typescript-eslint/no-require-imports
166180
const { existsSync, readFileSync } = require('fs');

0 commit comments

Comments
 (0)