feat(worker): scaffold Vite+React PWA with token storage and typed API client
This commit is contained in:
60
apps/worker/src/lib/api.test.ts
Normal file
60
apps/worker/src/lib/api.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { apiFetch, signIn, ApiError } from './api';
|
||||
import { getToken, setToken } from './auth-storage';
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('attaches the bearer token to requests', async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
setToken('tok');
|
||||
|
||||
await apiFetch('/api/me');
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mock.mock.calls[0];
|
||||
expect(url).toBe('http://localhost:3000/api/me');
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get('Authorization')).toBe('Bearer tok');
|
||||
});
|
||||
|
||||
it('throws ApiError on a non-2xx response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
||||
);
|
||||
|
||||
await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 });
|
||||
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it('signIn stores the token from the set-auth-token header', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(null, { status: 200, headers: { 'set-auth-token': 'xyz' } }),
|
||||
),
|
||||
);
|
||||
|
||||
await signIn('a@b.c', 'pw');
|
||||
|
||||
expect(getToken()).toBe('xyz');
|
||||
});
|
||||
|
||||
it('signIn throws when the header is missing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })));
|
||||
|
||||
await expect(signIn('a@b.c', 'pw')).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
47
apps/worker/src/lib/api.ts
Normal file
47
apps/worker/src/lib/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getToken, setToken } from './auth-storage';
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
|
||||
if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
|
||||
export async function signIn(email: string, password: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt');
|
||||
const token = res.headers.get('set-auth-token');
|
||||
if (!token) throw new ApiError(500, 'Geen token ontvangen');
|
||||
setToken(token);
|
||||
}
|
||||
|
||||
// Sign up affordance for testing: POST /api/auth/sign-up/email.
|
||||
export async function signUp(email: string, password: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/api/auth/sign-up/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name: email.split('@')[0] || 'Worker' }),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, 'Registreren mislukt');
|
||||
}
|
||||
15
apps/worker/src/lib/auth-storage.test.ts
Normal file
15
apps/worker/src/lib/auth-storage.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { getToken, setToken, clearToken } from './auth-storage';
|
||||
|
||||
describe('auth-storage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores, reads, and clears the token', () => {
|
||||
setToken('abc');
|
||||
expect(getToken()).toBe('abc');
|
||||
clearToken();
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
13
apps/worker/src/lib/auth-storage.ts
Normal file
13
apps/worker/src/lib/auth-storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const TOKEN_KEY = 'solelog.token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user