Skip to content

Commit d0cb32e

Browse files
Copilothotlong
andcommitted
refactor: replace custom createAuthClient with official better-auth client
- Install better-auth as dependency in @object-ui/auth - Rewrite createAuthClient.ts to delegate to better-auth/client createAuthClient - Add resolveAuthURL helper to support both relative and absolute baseURLs - Update MSW authHandlers to use better-auth endpoint name (forget-password) - Update createAuthClient tests for new better-auth backed implementation - All 81 existing tests pass, TypeScript compiles cleanly Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent f3edb28 commit d0cb32e

File tree

5 files changed

+202
-151
lines changed

5 files changed

+202
-151
lines changed

apps/console/src/mocks/authHandlers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* POST /sign-in/email — authenticate with email + password
1515
* GET /get-session — retrieve the current session
1616
* POST /sign-out — clear the session
17-
* POST /forgot-password — no-op acknowledgement
17+
* POST /forget-password — no-op acknowledgement (better-auth convention)
1818
* POST /reset-password — no-op acknowledgement
1919
* POST /update-user — update the current user's profile
2020
*/
@@ -163,8 +163,9 @@ export function createAuthHandlers(baseUrl: string): HttpHandler[] {
163163
}),
164164

165165
// ── Forgot Password (mock acknowledgement) ──────────────────────────
166-
http.post(`${p}/forgot-password`, () => {
167-
return HttpResponse.json({ success: true });
166+
// better-auth uses "forget-password" (not "forgot-password")
167+
http.post(`${p}/forget-password`, () => {
168+
return HttpResponse.json({ status: true });
168169
}),
169170

170171
// ── Reset Password (mock acknowledgement) ────────────────────────────

packages/auth/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"react": "^18.0.0 || ^19.0.0"
3535
},
3636
"dependencies": {
37-
"@object-ui/types": "workspace:*"
37+
"@object-ui/types": "workspace:*",
38+
"better-auth": "^1.5.4"
3839
},
3940
"devDependencies": {
4041
"@types/react": "19.2.14",
Lines changed: 106 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
11
/**
2-
* Tests for createAuthClient
2+
* Tests for createAuthClient (backed by official better-auth client)
33
*/
44

55
import { describe, it, expect, vi, beforeEach } from 'vitest';
66
import { createAuthClient } from '../createAuthClient';
77
import type { AuthClient } from '../types';
88

9-
describe('createAuthClient', () => {
10-
let client: AuthClient;
11-
let mockFetch: ReturnType<typeof vi.fn>;
12-
13-
beforeEach(() => {
14-
mockFetch = vi.fn();
15-
client = createAuthClient({ baseURL: '/api/auth', fetchFn: mockFetch });
9+
/**
10+
* Helper: creates a mock fetch that routes requests based on URL
11+
* and records every call for inspection.
12+
*/
13+
function createMockFetch(handlers: Record<string, { status?: number; body: unknown }>) {
14+
const calls: Array<{ url: string; method: string; body: string | null }> = [];
15+
const mockFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
16+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
17+
calls.push({ url, method: init?.method ?? 'GET', body: init?.body as string | null });
18+
for (const [pattern, handler] of Object.entries(handlers)) {
19+
if (url.includes(pattern)) {
20+
return new Response(JSON.stringify(handler.body), {
21+
status: handler.status ?? 200,
22+
headers: { 'Content-Type': 'application/json' },
23+
});
24+
}
25+
}
26+
return new Response(JSON.stringify({ message: 'Not found' }), {
27+
status: 404,
28+
headers: { 'Content-Type': 'application/json' },
29+
});
1630
});
31+
return { mockFn, calls };
32+
}
1733

34+
describe('createAuthClient', () => {
1835
it('creates a client with all expected methods', () => {
36+
const { mockFn } = createMockFetch({});
37+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
1938
expect(client).toHaveProperty('signIn');
2039
expect(client).toHaveProperty('signUp');
2140
expect(client).toHaveProperty('signOut');
@@ -26,156 +45,150 @@ describe('createAuthClient', () => {
2645
});
2746

2847
it('signIn sends POST to /sign-in/email', async () => {
29-
const mockResponse = {
30-
user: { id: '1', name: 'Test', email: 'test@test.com' },
31-
session: { token: 'tok123' },
32-
};
33-
mockFetch.mockResolvedValueOnce({
34-
ok: true,
35-
json: () => Promise.resolve(mockResponse),
48+
const { mockFn, calls } = createMockFetch({
49+
'/sign-in/email': {
50+
body: {
51+
user: { id: '1', name: 'Test', email: 'test@test.com' },
52+
session: { token: 'tok123', id: 's1', userId: '1', expiresAt: '2025-01-01' },
53+
},
54+
},
3655
});
56+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
3757

3858
const result = await client.signIn({ email: 'test@test.com', password: 'pass123' });
3959

40-
expect(mockFetch).toHaveBeenCalledWith(
41-
'/api/auth/sign-in/email',
42-
expect.objectContaining({
43-
method: 'POST',
44-
credentials: 'include',
45-
body: JSON.stringify({ email: 'test@test.com', password: 'pass123' }),
46-
}),
47-
);
60+
expect(calls).toHaveLength(1);
61+
expect(calls[0].url).toContain('/api/auth/sign-in/email');
62+
expect(calls[0].method).toBe('POST');
63+
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com', password: 'pass123' });
4864
expect(result.user.email).toBe('test@test.com');
4965
expect(result.session.token).toBe('tok123');
5066
});
5167

5268
it('signUp sends POST to /sign-up/email', async () => {
53-
const mockResponse = {
54-
user: { id: '2', name: 'New User', email: 'new@test.com' },
55-
session: { token: 'tok456' },
56-
};
57-
mockFetch.mockResolvedValueOnce({
58-
ok: true,
59-
json: () => Promise.resolve(mockResponse),
69+
const { mockFn, calls } = createMockFetch({
70+
'/sign-up/email': {
71+
body: {
72+
user: { id: '2', name: 'New User', email: 'new@test.com' },
73+
session: { token: 'tok456', id: 's2', userId: '2', expiresAt: '2025-01-01' },
74+
},
75+
},
6076
});
77+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
6178

6279
const result = await client.signUp({ name: 'New User', email: 'new@test.com', password: 'pass123' });
6380

64-
expect(mockFetch).toHaveBeenCalledWith(
65-
'/api/auth/sign-up/email',
66-
expect.objectContaining({ method: 'POST' }),
67-
);
81+
expect(calls).toHaveLength(1);
82+
expect(calls[0].url).toContain('/api/auth/sign-up/email');
83+
expect(calls[0].method).toBe('POST');
84+
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'new@test.com', name: 'New User' });
6885
expect(result.user.name).toBe('New User');
6986
});
7087

7188
it('signOut sends POST to /sign-out', async () => {
72-
mockFetch.mockResolvedValueOnce({
73-
ok: true,
74-
json: () => Promise.resolve({}),
89+
const { mockFn, calls } = createMockFetch({
90+
'/sign-out': { body: { success: true } },
7591
});
92+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
7693

7794
await client.signOut();
7895

79-
expect(mockFetch).toHaveBeenCalledWith(
80-
'/api/auth/sign-out',
81-
expect.objectContaining({ method: 'POST' }),
82-
);
96+
expect(calls).toHaveLength(1);
97+
expect(calls[0].url).toContain('/api/auth/sign-out');
98+
expect(calls[0].method).toBe('POST');
8399
});
84100

85101
it('getSession sends GET to /get-session', async () => {
86-
const mockSession = {
87-
user: { id: '1', name: 'Test', email: 'test@test.com' },
88-
session: { token: 'tok789' },
89-
};
90-
mockFetch.mockResolvedValueOnce({
91-
ok: true,
92-
json: () => Promise.resolve(mockSession),
102+
const { mockFn, calls } = createMockFetch({
103+
'/get-session': {
104+
body: {
105+
user: { id: '1', name: 'Test', email: 'test@test.com' },
106+
session: { token: 'tok789', id: 's1', userId: '1', expiresAt: '2025-01-01' },
107+
},
108+
},
93109
});
110+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
94111

95112
const result = await client.getSession();
96113

97-
expect(mockFetch).toHaveBeenCalledWith(
98-
'/api/auth/get-session',
99-
expect.objectContaining({ method: 'GET' }),
100-
);
114+
expect(calls).toHaveLength(1);
115+
expect(calls[0].url).toContain('/api/auth/get-session');
116+
expect(calls[0].method).toBe('GET');
101117
expect(result?.user.id).toBe('1');
102118
});
103119

104120
it('getSession returns null on failure', async () => {
105-
mockFetch.mockRejectedValueOnce(new Error('Network error'));
121+
const { mockFn } = createMockFetch({
122+
'/get-session': { status: 401, body: { message: 'Unauthorized' } },
123+
});
124+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
106125

107126
const result = await client.getSession();
108127
expect(result).toBeNull();
109128
});
110129

111-
it('forgotPassword sends POST to /forgot-password', async () => {
112-
mockFetch.mockResolvedValueOnce({
113-
ok: true,
114-
json: () => Promise.resolve({}),
130+
it('forgotPassword sends POST to /forget-password', async () => {
131+
const { mockFn, calls } = createMockFetch({
132+
'/forget-password': { body: { status: true } },
115133
});
134+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
116135

117136
await client.forgotPassword('test@test.com');
118137

119-
expect(mockFetch).toHaveBeenCalledWith(
120-
'/api/auth/forgot-password',
121-
expect.objectContaining({
122-
method: 'POST',
123-
body: JSON.stringify({ email: 'test@test.com' }),
124-
}),
125-
);
138+
expect(calls).toHaveLength(1);
139+
expect(calls[0].url).toContain('/api/auth/forget-password');
140+
expect(calls[0].method).toBe('POST');
141+
expect(JSON.parse(calls[0].body!)).toMatchObject({ email: 'test@test.com' });
126142
});
127143

128144
it('resetPassword sends POST to /reset-password', async () => {
129-
mockFetch.mockResolvedValueOnce({
130-
ok: true,
131-
json: () => Promise.resolve({}),
145+
const { mockFn, calls } = createMockFetch({
146+
'/reset-password': { body: { status: true } },
132147
});
148+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
133149

134150
await client.resetPassword('token123', 'newpass');
135151

136-
expect(mockFetch).toHaveBeenCalledWith(
137-
'/api/auth/reset-password',
138-
expect.objectContaining({
139-
method: 'POST',
140-
body: JSON.stringify({ token: 'token123', newPassword: 'newpass' }),
141-
}),
142-
);
152+
expect(calls).toHaveLength(1);
153+
expect(calls[0].url).toContain('/api/auth/reset-password');
154+
expect(calls[0].method).toBe('POST');
155+
expect(JSON.parse(calls[0].body!)).toMatchObject({ token: 'token123', newPassword: 'newpass' });
143156
});
144157

145158
it('throws error with server message on non-OK response', async () => {
146-
mockFetch.mockResolvedValueOnce({
147-
ok: false,
148-
status: 401,
149-
json: () => Promise.resolve({ message: 'Invalid credentials' }),
159+
const { mockFn } = createMockFetch({
160+
'/sign-in/email': {
161+
status: 401,
162+
body: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' },
163+
},
150164
});
165+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
151166

152167
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow('Invalid credentials');
153168
});
154169

155-
it('throws generic error when response has no message', async () => {
156-
mockFetch.mockResolvedValueOnce({
157-
ok: false,
158-
status: 500,
159-
json: () => Promise.reject(new Error('parse error')),
170+
it('throws error on non-OK response without message', async () => {
171+
const { mockFn } = createMockFetch({
172+
'/sign-in/email': { status: 500, body: {} },
160173
});
174+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
161175

162-
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow(
163-
'Auth request failed with status 500',
164-
);
176+
await expect(client.signIn({ email: 'x', password: 'y' })).rejects.toThrow();
165177
});
166178

167179
it('updateUser sends POST to /update-user and returns user', async () => {
168-
mockFetch.mockResolvedValueOnce({
169-
ok: true,
170-
json: () => Promise.resolve({ user: { id: '1', name: 'Updated', email: 'test@test.com' } }),
180+
const { mockFn, calls } = createMockFetch({
181+
'/update-user': {
182+
body: { user: { id: '1', name: 'Updated', email: 'test@test.com' } },
183+
},
171184
});
185+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
172186

173187
const result = await client.updateUser({ name: 'Updated' });
174188

189+
expect(calls).toHaveLength(1);
190+
expect(calls[0].url).toContain('/api/auth/update-user');
191+
expect(calls[0].method).toBe('POST');
175192
expect(result.name).toBe('Updated');
176-
expect(mockFetch).toHaveBeenCalledWith(
177-
'/api/auth/update-user',
178-
expect.objectContaining({ method: 'POST' }),
179-
);
180193
});
181194
});

0 commit comments

Comments
 (0)