Skip to content

Commit dd8c01a

Browse files
authored
Merge pull request #753 from mnfst/local
test: add unit tests for local mode coverage
2 parents e3f4a82 + ed0d8e6 commit dd8c01a

File tree

14 files changed

+574
-126
lines changed

14 files changed

+574
-126
lines changed

.changeset/plain-chicken-lead.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

codecov.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,19 @@ coverage:
33
project:
44
default:
55
target: auto
6+
threshold: 1%
67
patch:
78
default:
89
target: auto
910
flags:
1011
backend:
1112
paths:
1213
- packages/backend/src/
13-
carryforward: true
1414
frontend:
1515
paths:
1616
- packages/frontend/src/
17-
carryforward: true
1817
plugin:
1918
paths:
2019
- packages/openclaw-plugin/src/
21-
carryforward: true
2220
comment:
2321
layout: "reach,diff,flags"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import 'reflect-metadata';
2+
import { validate } from 'class-validator';
3+
import { plainToInstance } from 'class-transformer';
4+
import { MessagesQueryDto } from './messages-query.dto';
5+
6+
describe('MessagesQueryDto', () => {
7+
it('allows omitting all fields', async () => {
8+
const dto = plainToInstance(MessagesQueryDto, {});
9+
const errors = await validate(dto);
10+
expect(errors).toHaveLength(0);
11+
});
12+
13+
it('accepts valid string fields', async () => {
14+
const dto = plainToInstance(MessagesQueryDto, {
15+
range: '24h',
16+
status: 'ok',
17+
service_type: 'agent',
18+
model: 'gpt-4o',
19+
cursor: 'abc123',
20+
agent_name: 'my-bot',
21+
});
22+
const errors = await validate(dto);
23+
expect(errors).toHaveLength(0);
24+
});
25+
26+
it('accepts valid numeric fields', async () => {
27+
const dto = plainToInstance(MessagesQueryDto, {
28+
cost_min: 0,
29+
cost_max: 100,
30+
limit: 50,
31+
});
32+
const errors = await validate(dto);
33+
expect(errors).toHaveLength(0);
34+
});
35+
36+
it('rejects negative cost_min', async () => {
37+
const dto = plainToInstance(MessagesQueryDto, { cost_min: -1 });
38+
const errors = await validate(dto);
39+
expect(errors.length).toBeGreaterThan(0);
40+
});
41+
42+
it('rejects cost_max over 999999', async () => {
43+
const dto = plainToInstance(MessagesQueryDto, { cost_max: 1000000 });
44+
const errors = await validate(dto);
45+
expect(errors.length).toBeGreaterThan(0);
46+
});
47+
48+
it('rejects limit below 1', async () => {
49+
const dto = plainToInstance(MessagesQueryDto, { limit: 0 });
50+
const errors = await validate(dto);
51+
expect(errors.length).toBeGreaterThan(0);
52+
});
53+
54+
it('rejects limit above 200', async () => {
55+
const dto = plainToInstance(MessagesQueryDto, { limit: 201 });
56+
const errors = await validate(dto);
57+
expect(errors.length).toBeGreaterThan(0);
58+
});
59+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
jest.mock('better-auth/node', () => ({
2+
fromNodeHeaders: jest.fn((headers: Record<string, string>) => headers),
3+
}));
4+
5+
jest.mock('./auth.instance', () => ({
6+
auth: {
7+
api: {
8+
getSession: jest.fn(),
9+
},
10+
},
11+
}));
12+
13+
import { ExecutionContext } from '@nestjs/common';
14+
import { Reflector } from '@nestjs/core';
15+
import { SessionGuard } from './session.guard';
16+
17+
// eslint-disable-next-line @typescript-eslint/no-require-imports
18+
const { auth } = require('./auth.instance');
19+
20+
function createMockContext(overrides: {
21+
ip?: string;
22+
headers?: Record<string, string>;
23+
}): { context: ExecutionContext; request: Record<string, unknown> } {
24+
const request: Record<string, unknown> = {
25+
ip: overrides.ip ?? '127.0.0.1',
26+
headers: overrides.headers ?? {},
27+
};
28+
29+
const context = {
30+
getHandler: jest.fn(),
31+
getClass: jest.fn(),
32+
switchToHttp: () => ({
33+
getRequest: () => request,
34+
}),
35+
} as unknown as ExecutionContext;
36+
37+
return { context, request };
38+
}
39+
40+
describe('SessionGuard', () => {
41+
let guard: SessionGuard;
42+
let reflector: Reflector;
43+
44+
beforeEach(() => {
45+
reflector = new Reflector();
46+
guard = new SessionGuard(reflector);
47+
jest.clearAllMocks();
48+
});
49+
50+
it('allows public routes', async () => {
51+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
52+
const { context } = createMockContext({});
53+
54+
const result = await guard.canActivate(context);
55+
56+
expect(result).toBe(true);
57+
expect(auth.api.getSession).not.toHaveBeenCalled();
58+
});
59+
60+
it('passes through when X-API-Key header is present', async () => {
61+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
62+
const { context } = createMockContext({
63+
headers: { 'x-api-key': 'some-key' },
64+
});
65+
66+
const result = await guard.canActivate(context);
67+
68+
expect(result).toBe(true);
69+
expect(auth.api.getSession).not.toHaveBeenCalled();
70+
});
71+
72+
it('attaches user when session is valid', async () => {
73+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
74+
const mockSession = {
75+
user: { id: 'user-1', name: 'Test', email: 'test@test.com' },
76+
session: { id: 'session-1' },
77+
};
78+
(auth.api.getSession as jest.Mock).mockResolvedValue(mockSession);
79+
const { context, request } = createMockContext({});
80+
81+
const result = await guard.canActivate(context);
82+
83+
expect(result).toBe(true);
84+
expect(request['user']).toEqual(mockSession.user);
85+
expect(request['session']).toEqual(mockSession.session);
86+
});
87+
88+
it('returns true even when no session found', async () => {
89+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
90+
(auth.api.getSession as jest.Mock).mockResolvedValue(null);
91+
const { context, request } = createMockContext({});
92+
93+
const result = await guard.canActivate(context);
94+
95+
expect(result).toBe(true);
96+
expect(request['user']).toBeUndefined();
97+
});
98+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { API_KEY_PREFIX } from './api-key.constants';
2+
3+
describe('API_KEY_PREFIX', () => {
4+
it('equals mnfst_', () => {
5+
expect(API_KEY_PREFIX).toBe('mnfst_');
6+
});
7+
8+
it('is a string type', () => {
9+
expect(typeof API_KEY_PREFIX).toBe('string');
10+
});
11+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { DASHBOARD_CACHE_TTL_MS, MODEL_PRICES_CACHE_TTL_MS } from './cache.constants';
2+
3+
describe('Cache constants', () => {
4+
it('DASHBOARD_CACHE_TTL_MS is 5 seconds', () => {
5+
expect(DASHBOARD_CACHE_TTL_MS).toBe(5_000);
6+
});
7+
8+
it('MODEL_PRICES_CACHE_TTL_MS is 5 minutes', () => {
9+
expect(MODEL_PRICES_CACHE_TTL_MS).toBe(300_000);
10+
});
11+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
jest.mock('fs', () => ({
2+
existsSync: jest.fn(),
3+
readFileSync: jest.fn(),
4+
writeFileSync: jest.fn(),
5+
mkdirSync: jest.fn(),
6+
}));
7+
8+
import {
9+
LOCAL_USER_ID,
10+
LOCAL_EMAIL,
11+
LOCAL_DEFAULT_PORT,
12+
getLocalAuthSecret,
13+
getLocalPassword,
14+
} from './local-mode.constants';
15+
16+
// eslint-disable-next-line @typescript-eslint/no-require-imports
17+
const fs = require('fs');
18+
19+
describe('local-mode.constants', () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
describe('static constants', () => {
25+
it('exports LOCAL_USER_ID', () => {
26+
expect(LOCAL_USER_ID).toBe('local-user-001');
27+
});
28+
29+
it('exports LOCAL_EMAIL', () => {
30+
expect(LOCAL_EMAIL).toBe('local@manifest.local');
31+
});
32+
33+
it('exports LOCAL_DEFAULT_PORT', () => {
34+
expect(LOCAL_DEFAULT_PORT).toBe(2099);
35+
});
36+
});
37+
38+
describe('getLocalAuthSecret', () => {
39+
it('generates a random secret when config file does not exist', () => {
40+
(fs.existsSync as jest.Mock).mockReturnValue(false);
41+
42+
const secret = getLocalAuthSecret();
43+
44+
expect(secret).toHaveLength(64); // 32 bytes hex
45+
expect(fs.writeFileSync).toHaveBeenCalled();
46+
});
47+
48+
it('returns existing secret from config file', () => {
49+
(fs.existsSync as jest.Mock).mockReturnValue(true);
50+
(fs.readFileSync as jest.Mock).mockReturnValue(
51+
JSON.stringify({ authSecret: 'a'.repeat(64) }),
52+
);
53+
54+
const secret = getLocalAuthSecret();
55+
56+
expect(secret).toBe('a'.repeat(64));
57+
expect(fs.writeFileSync).not.toHaveBeenCalled();
58+
});
59+
60+
it('regenerates when existing secret is too short', () => {
61+
(fs.existsSync as jest.Mock).mockReturnValue(true);
62+
(fs.readFileSync as jest.Mock).mockReturnValue(
63+
JSON.stringify({ authSecret: 'too-short' }),
64+
);
65+
66+
const secret = getLocalAuthSecret();
67+
68+
expect(secret).toHaveLength(64);
69+
expect(fs.writeFileSync).toHaveBeenCalled();
70+
});
71+
72+
it('regenerates when config file is corrupted', () => {
73+
(fs.existsSync as jest.Mock)
74+
.mockReturnValueOnce(true) // ensureConfigDir
75+
.mockReturnValueOnce(true); // config file exists
76+
(fs.readFileSync as jest.Mock).mockReturnValue('not-json{{{');
77+
78+
const secret = getLocalAuthSecret();
79+
80+
expect(secret).toHaveLength(64);
81+
expect(fs.writeFileSync).toHaveBeenCalled();
82+
});
83+
84+
it('creates config directory with 0o700 permissions', () => {
85+
(fs.existsSync as jest.Mock).mockReturnValue(false);
86+
87+
getLocalAuthSecret();
88+
89+
expect(fs.mkdirSync).toHaveBeenCalledWith(
90+
expect.any(String),
91+
expect.objectContaining({ recursive: true, mode: 0o700 }),
92+
);
93+
});
94+
});
95+
96+
describe('getLocalPassword', () => {
97+
it('generates a random password when config file does not exist', () => {
98+
(fs.existsSync as jest.Mock).mockReturnValue(false);
99+
100+
const password = getLocalPassword();
101+
102+
expect(password.length).toBeGreaterThanOrEqual(16);
103+
expect(fs.writeFileSync).toHaveBeenCalled();
104+
});
105+
106+
it('returns existing password from config file', () => {
107+
(fs.existsSync as jest.Mock).mockReturnValue(true);
108+
(fs.readFileSync as jest.Mock).mockReturnValue(
109+
JSON.stringify({ localPassword: 'a-valid-password-long-enough' }),
110+
);
111+
112+
const password = getLocalPassword();
113+
114+
expect(password).toBe('a-valid-password-long-enough');
115+
expect(fs.writeFileSync).not.toHaveBeenCalled();
116+
});
117+
118+
it('regenerates when existing password is too short', () => {
119+
(fs.existsSync as jest.Mock).mockReturnValue(true);
120+
(fs.readFileSync as jest.Mock).mockReturnValue(
121+
JSON.stringify({ localPassword: 'short' }),
122+
);
123+
124+
const password = getLocalPassword();
125+
126+
expect(password.length).toBeGreaterThanOrEqual(16);
127+
expect(fs.writeFileSync).toHaveBeenCalled();
128+
});
129+
130+
it('preserves existing config fields when writing', () => {
131+
(fs.existsSync as jest.Mock)
132+
.mockReturnValueOnce(true) // ensureConfigDir
133+
.mockReturnValueOnce(true) // readConfig check
134+
.mockReturnValueOnce(true) // writeConfig ensureConfigDir
135+
.mockReturnValueOnce(false); // writeConfig config file (doesn't matter)
136+
(fs.readFileSync as jest.Mock).mockReturnValue(
137+
JSON.stringify({ apiKey: 'mnfst_existing', authSecret: 'x'.repeat(64) }),
138+
);
139+
140+
getLocalPassword();
141+
142+
const writeCall = (fs.writeFileSync as jest.Mock).mock.calls[0];
143+
const written = JSON.parse(writeCall[1]);
144+
expect(written.apiKey).toBe('mnfst_existing');
145+
expect(written.authSecret).toBe('x'.repeat(64));
146+
expect(written.localPassword).toBeDefined();
147+
});
148+
149+
it('writes config file with 0o600 permissions', () => {
150+
(fs.existsSync as jest.Mock).mockReturnValue(false);
151+
152+
getLocalPassword();
153+
154+
expect(fs.writeFileSync).toHaveBeenCalledWith(
155+
expect.any(String),
156+
expect.any(String),
157+
expect.objectContaining({ mode: 0o600 }),
158+
);
159+
});
160+
});
161+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { IS_PUBLIC_KEY, Public } from './public.decorator';
2+
3+
describe('Public decorator', () => {
4+
it('exports IS_PUBLIC_KEY as isPublic', () => {
5+
expect(IS_PUBLIC_KEY).toBe('isPublic');
6+
});
7+
8+
it('Public() returns a decorator function', () => {
9+
const decorator = Public();
10+
expect(typeof decorator).toBe('function');
11+
});
12+
});

0 commit comments

Comments
 (0)