Skip to content

Commit 747effc

Browse files
committed
feat: add Vitest configuration and tests for login, logout, and project commands
1 parent c6e6def commit 747effc

File tree

15 files changed

+3584
-3
lines changed

15 files changed

+3584
-3
lines changed

package-lock.json

Lines changed: 2452 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"scripts": {
1111
"build": "tsup",
1212
"dev": "tsup --watch",
13-
"typecheck": "tsc --noEmit"
13+
"typecheck": "tsc --noEmit",
14+
"test": "vitest run",
15+
"test:watch": "vitest"
1416
},
1517
"keywords": [
1618
"yavy",
@@ -40,7 +42,8 @@
4042
"devDependencies": {
4143
"@types/node": "^22.0.0",
4244
"tsup": "^8.4.0",
43-
"typescript": "^5.7.0"
45+
"typescript": "^5.7.0",
46+
"vitest": "^3.0.0"
4447
},
4548
"engines": {
4649
"node": ">=18"

src/__test__/helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function createMockResponse(body: unknown, status = 200): Response {
2+
return {
3+
ok: status >= 200 && status < 300,
4+
status,
5+
json: () => Promise.resolve(body),
6+
text: () => Promise.resolve(JSON.stringify(body)),
7+
headers: new Headers(),
8+
} as Response;
9+
}

src/api/client.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { createMockResponse } from '../__test__/helpers.js';
3+
import { YavyApiClient } from './client.js';
4+
5+
vi.mock('../auth/store.js', () => ({
6+
getAccessToken: vi.fn(),
7+
}));
8+
9+
vi.mock('../config.js', () => ({
10+
YAVY_BASE_URL: 'https://test.yavy.dev',
11+
YAVY_CLIENT_ID: 'test-client-id',
12+
}));
13+
14+
import { getAccessToken } from '../auth/store.js';
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks();
18+
});
19+
20+
describe('YavyApiClient.create', () => {
21+
beforeEach(() => {
22+
vi.stubGlobal('fetch', vi.fn());
23+
});
24+
25+
it('returns client instance when token exists', async () => {
26+
vi.mocked(getAccessToken).mockResolvedValue('my-token');
27+
const client = await YavyApiClient.create();
28+
expect(client).toBeInstanceOf(YavyApiClient);
29+
});
30+
31+
it('throws "Not authenticated" when token is null', async () => {
32+
vi.mocked(getAccessToken).mockResolvedValue(null);
33+
await expect(YavyApiClient.create()).rejects.toThrow('Not authenticated');
34+
});
35+
});
36+
37+
describe('listProjects', () => {
38+
let client: YavyApiClient;
39+
40+
beforeEach(() => {
41+
vi.stubGlobal('fetch', vi.fn());
42+
vi.mocked(getAccessToken).mockResolvedValue('test-token');
43+
});
44+
45+
it('sends GET to /api/v1/projects with Bearer auth', async () => {
46+
const projects = [{ id: 1, name: 'Test Project' }];
47+
vi.mocked(fetch).mockResolvedValue(createMockResponse({ data: projects }));
48+
49+
client = await YavyApiClient.create();
50+
const result = await client.listProjects();
51+
52+
expect(fetch).toHaveBeenLastCalledWith(
53+
'https://test.yavy.dev/api/v1/projects',
54+
expect.objectContaining({
55+
method: 'GET',
56+
headers: expect.objectContaining({
57+
Authorization: 'Bearer test-token',
58+
}),
59+
}),
60+
);
61+
expect(result).toEqual(projects);
62+
});
63+
});
64+
65+
describe('generateSkill', () => {
66+
let client: YavyApiClient;
67+
68+
beforeEach(async () => {
69+
vi.stubGlobal('fetch', vi.fn());
70+
vi.mocked(getAccessToken).mockResolvedValue('test-token');
71+
client = await YavyApiClient.create();
72+
});
73+
74+
it('sends POST to correct path', async () => {
75+
const skill = { content: '# Skill', format: 'md', generated_at: '2024-01-01', token_count: 100 };
76+
vi.mocked(fetch).mockResolvedValue(createMockResponse(skill));
77+
78+
await client.generateSkill('my-org', 'my-project');
79+
80+
expect(fetch).toHaveBeenLastCalledWith(
81+
'https://test.yavy.dev/api/v1/my-org/my-project/skill/generate',
82+
expect.objectContaining({ method: 'POST' }),
83+
);
84+
});
85+
86+
it('sends { force: true } body when force=true', async () => {
87+
vi.mocked(fetch).mockResolvedValue(createMockResponse({ content: '' }));
88+
89+
await client.generateSkill('org', 'proj', true);
90+
91+
const callArgs = vi.mocked(fetch).mock.calls[0];
92+
const opts = callArgs[1] as RequestInit;
93+
expect(JSON.parse(opts.body as string)).toEqual({ force: true });
94+
expect(opts.headers).toHaveProperty('Content-Type', 'application/json');
95+
});
96+
97+
it('sends no body when force=false', async () => {
98+
vi.mocked(fetch).mockResolvedValue(createMockResponse({ content: '' }));
99+
100+
await client.generateSkill('org', 'proj', false);
101+
102+
const callArgs = vi.mocked(fetch).mock.calls[0];
103+
const opts = callArgs[1] as RequestInit;
104+
expect(opts.body).toBeUndefined();
105+
});
106+
});
107+
108+
describe('getSkill', () => {
109+
it('sends GET to correct path with auth header', async () => {
110+
vi.stubGlobal('fetch', vi.fn());
111+
vi.mocked(getAccessToken).mockResolvedValue('test-token');
112+
vi.mocked(fetch).mockResolvedValue(createMockResponse({ content: '# Skill' }));
113+
114+
const client = await YavyApiClient.create();
115+
await client.getSkill('my-org', 'my-project');
116+
117+
expect(fetch).toHaveBeenLastCalledWith(
118+
'https://test.yavy.dev/api/v1/my-org/my-project/skill',
119+
expect.objectContaining({
120+
method: 'GET',
121+
headers: expect.objectContaining({
122+
Authorization: 'Bearer test-token',
123+
}),
124+
}),
125+
);
126+
});
127+
});
128+
129+
describe('error handling', () => {
130+
let client: YavyApiClient;
131+
132+
beforeEach(async () => {
133+
vi.stubGlobal('fetch', vi.fn());
134+
vi.mocked(getAccessToken).mockResolvedValue('test-token');
135+
client = await YavyApiClient.create();
136+
});
137+
138+
it('throws "Authentication expired" on 401', async () => {
139+
vi.mocked(fetch).mockResolvedValue(createMockResponse({}, 401));
140+
await expect(client.listProjects()).rejects.toThrow('Authentication expired');
141+
});
142+
143+
it('throws error message from JSON body on non-ok response', async () => {
144+
vi.mocked(fetch).mockResolvedValue(createMockResponse({ error: 'Project not found' }, 404));
145+
await expect(client.listProjects()).rejects.toThrow('Project not found');
146+
});
147+
148+
it('throws generic status message when error body has no error field', async () => {
149+
vi.mocked(fetch).mockResolvedValue(createMockResponse({}, 500));
150+
await expect(client.listProjects()).rejects.toThrow('API request failed with status 500');
151+
});
152+
153+
it('throws generic message when error body is not JSON', async () => {
154+
vi.mocked(fetch).mockResolvedValue({
155+
ok: false,
156+
status: 502,
157+
json: () => Promise.reject(new Error('not json')),
158+
headers: new Headers(),
159+
} as Response);
160+
await expect(client.listProjects()).rejects.toThrow('API request failed with status 502');
161+
});
162+
});

0 commit comments

Comments
 (0)