snice 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/snice.js +4 -5
- package/bin/templates/CLAUDE.md +25 -3
- package/bin/templates/pwa/README.md +188 -0
- package/bin/templates/pwa/global.d.ts +10 -0
- package/bin/templates/pwa/index.html +16 -0
- package/bin/templates/pwa/package.json +32 -0
- package/bin/templates/pwa/public/icons/.gitkeep +6 -0
- package/bin/templates/pwa/src/daemons/notifications.ts +148 -0
- package/bin/templates/pwa/src/fetcher.ts +15 -0
- package/bin/templates/pwa/src/guards/auth.ts +12 -0
- package/bin/templates/pwa/src/main.ts +42 -0
- package/bin/templates/pwa/src/middleware/auth.ts +16 -0
- package/bin/templates/pwa/src/middleware/error.ts +36 -0
- package/bin/templates/pwa/src/middleware/retry.ts +31 -0
- package/bin/templates/pwa/src/pages/dashboard.ts +143 -0
- package/bin/templates/pwa/src/pages/login.ts +161 -0
- package/bin/templates/pwa/src/pages/notifications.ts +156 -0
- package/bin/templates/pwa/src/pages/profile.ts +164 -0
- package/bin/templates/pwa/src/router.ts +20 -0
- package/bin/templates/pwa/src/services/auth.ts +48 -0
- package/bin/templates/pwa/src/services/jwt.ts +35 -0
- package/bin/templates/pwa/src/services/storage.ts +24 -0
- package/bin/templates/pwa/src/styles/global.css +55 -0
- package/bin/templates/pwa/src/types/auth.ts +21 -0
- package/bin/templates/pwa/src/types/notifications.ts +9 -0
- package/bin/templates/pwa/tests/helpers/test-utils.ts +84 -0
- package/bin/templates/pwa/tests/middleware/auth.test.ts +67 -0
- package/bin/templates/pwa/tests/middleware/error.test.ts +105 -0
- package/bin/templates/pwa/tests/middleware/retry.test.ts +103 -0
- package/bin/templates/pwa/tests/services/auth.test.ts +89 -0
- package/bin/templates/pwa/tests/services/jwt.test.ts +76 -0
- package/bin/templates/pwa/tests/services/storage.test.ts +69 -0
- package/bin/templates/{social → pwa}/tsconfig.json +11 -10
- package/bin/templates/pwa/vite.config.ts +94 -0
- package/bin/templates/{social/vite.config.ts → pwa/vitest.config.ts} +12 -17
- package/dist/components/music-player/snice-music-player.d.ts +72 -0
- package/dist/components/music-player/snice-music-player.js +730 -0
- package/dist/components/music-player/snice-music-player.js.map +1 -0
- package/dist/components/music-player/snice-music-player.types.d.ts +43 -0
- package/dist/components/timer/snice-timer.d.ts +27 -0
- package/dist/components/timer/snice-timer.js +197 -0
- package/dist/components/timer/snice-timer.js.map +1 -0
- package/dist/components/timer/snice-timer.types.d.ts +10 -0
- package/dist/fetcher.d.ts +65 -0
- package/dist/index.cjs +92 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +92 -4
- package/dist/index.esm.js.map +1 -1
- package/dist/index.iife.js +92 -3
- package/dist/index.iife.js.map +1 -1
- package/dist/symbols.cjs +1 -1
- package/dist/symbols.esm.js +1 -1
- package/dist/transitions.cjs +1 -1
- package/dist/transitions.esm.js +1 -1
- package/dist/types/context.d.ts +7 -1
- package/dist/types/router-options.d.ts +6 -0
- package/docs/ai/api.md +33 -1
- package/docs/ai/components/music-player.md +134 -0
- package/docs/ai/components/timer.md +43 -0
- package/docs/ai/patterns.md +48 -1
- package/docs/components/music-player.md +314 -0
- package/docs/components/timer.md +143 -0
- package/docs/fetcher.md +447 -0
- package/docs/routing.md +11 -8
- package/package.json +2 -1
- package/bin/templates/social/README.md +0 -42
- package/bin/templates/social/global.d.ts +0 -14
- package/bin/templates/social/index.html +0 -13
- package/bin/templates/social/package.json +0 -21
- package/bin/templates/social/src/main.ts +0 -33
- package/bin/templates/social/src/pages/feed-page.ts +0 -111
- package/bin/templates/social/src/pages/messages-page.ts +0 -102
- package/bin/templates/social/src/pages/not-found-page.ts +0 -46
- package/bin/templates/social/src/pages/profile-page.ts +0 -99
- package/bin/templates/social/src/pages/settings-page.ts +0 -119
- package/bin/templates/social/src/router.ts +0 -9
- package/bin/templates/social/src/styles/global.css +0 -156
- /package/bin/templates/{social → pwa}/public/vite.svg +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface JWTPayload {
|
|
2
|
+
exp?: number;
|
|
3
|
+
iat?: number;
|
|
4
|
+
sub?: string;
|
|
5
|
+
[key: string]: any;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function decodeJWT(token: string): JWTPayload | null {
|
|
9
|
+
try {
|
|
10
|
+
const parts = token.split('.');
|
|
11
|
+
if (parts.length !== 3) return null;
|
|
12
|
+
|
|
13
|
+
const payload = parts[1];
|
|
14
|
+
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
15
|
+
return JSON.parse(decoded);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error('Failed to decode JWT:', err);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isTokenExpired(token: string): boolean {
|
|
23
|
+
const payload = decodeJWT(token);
|
|
24
|
+
if (!payload || !payload.exp) return true;
|
|
25
|
+
|
|
26
|
+
const now = Math.floor(Date.now() / 1000);
|
|
27
|
+
return payload.exp < now;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getTokenExpiration(token: string): Date | null {
|
|
31
|
+
const payload = decodeJWT(token);
|
|
32
|
+
if (!payload || !payload.exp) return null;
|
|
33
|
+
|
|
34
|
+
return new Date(payload.exp * 1000);
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const TOKEN_KEY = 'auth_token';
|
|
2
|
+
const USER_KEY = 'auth_user';
|
|
3
|
+
|
|
4
|
+
export function getToken(): string | null {
|
|
5
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function setToken(token: string): void {
|
|
9
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clearToken(): void {
|
|
13
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
14
|
+
localStorage.removeItem(USER_KEY);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getUser<T>(): T | null {
|
|
18
|
+
const user = localStorage.getItem(USER_KEY);
|
|
19
|
+
return user ? JSON.parse(user) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setUser<T>(user: T): void {
|
|
23
|
+
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--primary-color: #6366f1;
|
|
3
|
+
--secondary-color: #8b5cf6;
|
|
4
|
+
--success-color: #10b981;
|
|
5
|
+
--warning-color: #f59e0b;
|
|
6
|
+
--danger-color: #ef4444;
|
|
7
|
+
--info-color: #3b82f6;
|
|
8
|
+
|
|
9
|
+
--bg-primary: #ffffff;
|
|
10
|
+
--bg-secondary: #f9fafb;
|
|
11
|
+
--text-color: #1f2937;
|
|
12
|
+
--text-light: #6b7280;
|
|
13
|
+
--border-color: #e5e7eb;
|
|
14
|
+
|
|
15
|
+
--radius-sm: 0.375rem;
|
|
16
|
+
--radius-md: 0.5rem;
|
|
17
|
+
--radius-lg: 0.75rem;
|
|
18
|
+
|
|
19
|
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
20
|
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
21
|
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
|
22
|
+
|
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
24
|
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
25
|
+
sans-serif;
|
|
26
|
+
line-height: 1.5;
|
|
27
|
+
font-weight: 400;
|
|
28
|
+
color-scheme: light;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
* {
|
|
32
|
+
box-sizing: border-box;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
body {
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
background-color: var(--bg-secondary);
|
|
39
|
+
color: var(--text-color);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#app {
|
|
43
|
+
min-height: 100vh;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@media (prefers-color-scheme: dark) {
|
|
47
|
+
:root {
|
|
48
|
+
--bg-primary: #1f2937;
|
|
49
|
+
--bg-secondary: #111827;
|
|
50
|
+
--text-color: #f9fafb;
|
|
51
|
+
--text-light: #9ca3af;
|
|
52
|
+
--border-color: #374151;
|
|
53
|
+
color-scheme: dark;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface User {
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
name: string;
|
|
5
|
+
avatar?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LoginCredentials {
|
|
9
|
+
email: string;
|
|
10
|
+
password: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LoginResponse {
|
|
14
|
+
token: string;
|
|
15
|
+
user: User;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Principal {
|
|
19
|
+
user: User | null;
|
|
20
|
+
isAuthenticated: boolean;
|
|
21
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait for a specified number of milliseconds
|
|
3
|
+
*/
|
|
4
|
+
export async function waitFor(ms: number): Promise<void> {
|
|
5
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wait for a condition to become true
|
|
10
|
+
*/
|
|
11
|
+
export async function waitUntil(
|
|
12
|
+
condition: () => boolean,
|
|
13
|
+
timeout = 1000,
|
|
14
|
+
interval = 10
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
while (!condition()) {
|
|
18
|
+
if (Date.now() - start > timeout) {
|
|
19
|
+
throw new Error('Timeout waiting for condition');
|
|
20
|
+
}
|
|
21
|
+
await waitFor(interval);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a container div and append it to document.body
|
|
27
|
+
*/
|
|
28
|
+
export function createContainer(id = 'app'): HTMLDivElement {
|
|
29
|
+
const container = document.createElement('div');
|
|
30
|
+
container.id = id;
|
|
31
|
+
document.body.appendChild(container);
|
|
32
|
+
return container;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clean up the document body
|
|
37
|
+
*/
|
|
38
|
+
export function cleanup(): void {
|
|
39
|
+
document.body.innerHTML = '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mock localStorage for tests
|
|
44
|
+
*/
|
|
45
|
+
export function mockLocalStorage(): {
|
|
46
|
+
getItem: ReturnType<typeof vi.fn>;
|
|
47
|
+
setItem: ReturnType<typeof vi.fn>;
|
|
48
|
+
removeItem: ReturnType<typeof vi.fn>;
|
|
49
|
+
clear: ReturnType<typeof vi.fn>;
|
|
50
|
+
} {
|
|
51
|
+
const store: Record<string, string> = {};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
getItem: vi.fn((key: string) => store[key] || null),
|
|
55
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
56
|
+
store[key] = value;
|
|
57
|
+
}),
|
|
58
|
+
removeItem: vi.fn((key: string) => {
|
|
59
|
+
delete store[key];
|
|
60
|
+
}),
|
|
61
|
+
clear: vi.fn(() => {
|
|
62
|
+
Object.keys(store).forEach(key => delete store[key]);
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Mock fetch for tests
|
|
69
|
+
*/
|
|
70
|
+
export function mockFetch(
|
|
71
|
+
response: unknown = {},
|
|
72
|
+
status = 200,
|
|
73
|
+
ok = true
|
|
74
|
+
): ReturnType<typeof vi.fn> {
|
|
75
|
+
return vi.fn(() =>
|
|
76
|
+
Promise.resolve({
|
|
77
|
+
ok,
|
|
78
|
+
status,
|
|
79
|
+
json: () => Promise.resolve(response),
|
|
80
|
+
text: () => Promise.resolve(JSON.stringify(response)),
|
|
81
|
+
headers: new Headers(),
|
|
82
|
+
} as Response)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { Context } from 'snice';
|
|
3
|
+
import { authMiddleware } from '../../src/middleware/auth';
|
|
4
|
+
import * as storage from '../../src/services/storage';
|
|
5
|
+
|
|
6
|
+
describe('Auth Middleware', () => {
|
|
7
|
+
let mockContext: Context;
|
|
8
|
+
let mockRequest: Request;
|
|
9
|
+
let mockNext: ReturnType<typeof vi.fn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
localStorage.clear();
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
|
|
15
|
+
// Create mock context
|
|
16
|
+
mockContext = {
|
|
17
|
+
application: {
|
|
18
|
+
principal: {
|
|
19
|
+
user: null,
|
|
20
|
+
isAuthenticated: false,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
navigation: {
|
|
24
|
+
route: '/',
|
|
25
|
+
params: {},
|
|
26
|
+
},
|
|
27
|
+
update: vi.fn(),
|
|
28
|
+
} as unknown as Context;
|
|
29
|
+
|
|
30
|
+
// Create mock request
|
|
31
|
+
mockRequest = new Request('https://api.example.com/data', {
|
|
32
|
+
headers: new Headers(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Create mock next function
|
|
36
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
37
|
+
mockNext = vi.fn(() => Promise.resolve(mockResponse));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should add Authorization header when token exists', async () => {
|
|
41
|
+
const token = 'test-token-123';
|
|
42
|
+
storage.setToken(token);
|
|
43
|
+
|
|
44
|
+
await authMiddleware.call(mockContext, mockRequest, mockNext);
|
|
45
|
+
|
|
46
|
+
expect(mockRequest.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
|
47
|
+
expect(mockNext).toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should not add Authorization header when no token exists', async () => {
|
|
51
|
+
await authMiddleware.call(mockContext, mockRequest, mockNext);
|
|
52
|
+
|
|
53
|
+
expect(mockRequest.headers.get('Authorization')).toBeNull();
|
|
54
|
+
expect(mockNext).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call next() and return response', async () => {
|
|
58
|
+
const token = 'test-token-123';
|
|
59
|
+
storage.setToken(token);
|
|
60
|
+
|
|
61
|
+
const response = await authMiddleware.call(mockContext, mockRequest, mockNext);
|
|
62
|
+
|
|
63
|
+
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(response).toBeInstanceOf(Response);
|
|
65
|
+
expect(response.status).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { Context } from 'snice';
|
|
3
|
+
import { errorMiddleware } from '../../src/middleware/error';
|
|
4
|
+
import * as storage from '../../src/services/storage';
|
|
5
|
+
import type { Principal } from '../../src/types/auth';
|
|
6
|
+
|
|
7
|
+
describe('Error Middleware', () => {
|
|
8
|
+
let mockContext: Context;
|
|
9
|
+
let mockNext: ReturnType<typeof vi.fn>;
|
|
10
|
+
let originalLocation: Location;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
localStorage.clear();
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
|
|
16
|
+
// Save original location
|
|
17
|
+
originalLocation = window.location;
|
|
18
|
+
|
|
19
|
+
// Mock window.location
|
|
20
|
+
delete (window as any).location;
|
|
21
|
+
window.location = { href: '' } as Location;
|
|
22
|
+
|
|
23
|
+
// Create mock context
|
|
24
|
+
mockContext = {
|
|
25
|
+
application: {
|
|
26
|
+
principal: {
|
|
27
|
+
user: { id: '1', name: 'Test', email: 'test@example.com' },
|
|
28
|
+
isAuthenticated: true,
|
|
29
|
+
} as Principal,
|
|
30
|
+
},
|
|
31
|
+
navigation: {
|
|
32
|
+
route: '/',
|
|
33
|
+
params: {},
|
|
34
|
+
},
|
|
35
|
+
update: vi.fn(),
|
|
36
|
+
} as unknown as Context;
|
|
37
|
+
|
|
38
|
+
// Create mock next function
|
|
39
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
40
|
+
mockNext = vi.fn(() => Promise.resolve(mockResponse));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
window.location = originalLocation;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should pass through successful responses', async () => {
|
|
48
|
+
const mockResponse = new Response('{"data":"ok"}', { status: 200 });
|
|
49
|
+
const response = await errorMiddleware.call(mockContext, mockResponse, mockNext);
|
|
50
|
+
|
|
51
|
+
expect(mockNext).toHaveBeenCalled();
|
|
52
|
+
expect(response).toBeInstanceOf(Response);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle 401 unauthorized by clearing token and redirecting', async () => {
|
|
56
|
+
storage.setToken('test-token');
|
|
57
|
+
const mockResponse = new Response('Unauthorized', { status: 401 });
|
|
58
|
+
|
|
59
|
+
await expect(
|
|
60
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
61
|
+
).rejects.toThrow('Unauthorized - redirecting to login');
|
|
62
|
+
|
|
63
|
+
expect(storage.getToken()).toBeNull();
|
|
64
|
+
const principal = mockContext.application.principal as Principal;
|
|
65
|
+
expect(principal.user).toBeNull();
|
|
66
|
+
expect(principal.isAuthenticated).toBe(false);
|
|
67
|
+
expect(window.location.href).toBe('#/login');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw error for failed requests with JSON error message', async () => {
|
|
71
|
+
const mockResponse = new Response(
|
|
72
|
+
JSON.stringify({ message: 'Custom error message' }),
|
|
73
|
+
{
|
|
74
|
+
status: 400,
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
81
|
+
).rejects.toThrow('Custom error message');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should throw error for failed requests without JSON body', async () => {
|
|
85
|
+
const mockResponse = new Response('Internal Server Error', {
|
|
86
|
+
status: 500,
|
|
87
|
+
headers: { 'content-type': 'text/plain' },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
92
|
+
).rejects.toThrow('Request failed with status 500');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should throw generic error for failed JSON requests without message', async () => {
|
|
96
|
+
const mockResponse = new Response(JSON.stringify({}), {
|
|
97
|
+
status: 400,
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
103
|
+
).rejects.toThrow('Request failed with status 400');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { Context } from 'snice';
|
|
3
|
+
import { createRetryMiddleware } from '../../src/middleware/retry';
|
|
4
|
+
|
|
5
|
+
describe('Retry Middleware', () => {
|
|
6
|
+
let mockContext: Context;
|
|
7
|
+
let mockRequest: Request;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
|
|
12
|
+
mockContext = {
|
|
13
|
+
application: {
|
|
14
|
+
principal: {
|
|
15
|
+
user: null,
|
|
16
|
+
isAuthenticated: false,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
navigation: {
|
|
20
|
+
route: '/',
|
|
21
|
+
params: {},
|
|
22
|
+
},
|
|
23
|
+
update: vi.fn(),
|
|
24
|
+
} as unknown as Context;
|
|
25
|
+
|
|
26
|
+
mockRequest = new Request('https://api.example.com/data');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return response on first successful attempt', async () => {
|
|
30
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
31
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
32
|
+
const mockNext = vi.fn(() => Promise.resolve(mockResponse));
|
|
33
|
+
|
|
34
|
+
const response = await retryMiddleware.call(mockContext, mockRequest, mockNext);
|
|
35
|
+
|
|
36
|
+
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(response).toBe(mockResponse);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should retry on failure and eventually succeed', async () => {
|
|
41
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
42
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
43
|
+
const mockNext = vi
|
|
44
|
+
.fn()
|
|
45
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
46
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
47
|
+
.mockResolvedValueOnce(mockResponse);
|
|
48
|
+
|
|
49
|
+
const response = await retryMiddleware.call(mockContext, mockRequest, mockNext);
|
|
50
|
+
|
|
51
|
+
expect(mockNext).toHaveBeenCalledTimes(3);
|
|
52
|
+
expect(response).toBe(mockResponse);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw error after max retries', async () => {
|
|
56
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
57
|
+
const error = new Error('Network error');
|
|
58
|
+
const mockNext = vi.fn(() => Promise.reject(error));
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
retryMiddleware.call(mockContext, mockRequest, mockNext)
|
|
62
|
+
).rejects.toThrow('Network error');
|
|
63
|
+
|
|
64
|
+
expect(mockNext).toHaveBeenCalledTimes(3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should not retry on Unauthorized errors', async () => {
|
|
68
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
69
|
+
const error = new Error('Unauthorized');
|
|
70
|
+
const mockNext = vi.fn(() => Promise.reject(error));
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
retryMiddleware.call(mockContext, mockRequest, mockNext)
|
|
74
|
+
).rejects.toThrow('Unauthorized');
|
|
75
|
+
|
|
76
|
+
// Should only try once, no retries
|
|
77
|
+
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should use exponential backoff', async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const retryMiddleware = createRetryMiddleware(3, 100);
|
|
85
|
+
const mockNext = vi.fn(() => Promise.reject(new Error('Network error')));
|
|
86
|
+
|
|
87
|
+
const promise = retryMiddleware.call(mockContext, mockRequest, mockNext).catch(err => err);
|
|
88
|
+
|
|
89
|
+
// Run all timers to completion
|
|
90
|
+
await vi.runAllTimersAsync();
|
|
91
|
+
|
|
92
|
+
// All 3 attempts should have been made
|
|
93
|
+
expect(mockNext).toHaveBeenCalledTimes(3);
|
|
94
|
+
|
|
95
|
+
// Promise should have rejected with network error
|
|
96
|
+
const result = await promise;
|
|
97
|
+
expect(result).toBeInstanceOf(Error);
|
|
98
|
+
expect(result.message).toBe('Network error');
|
|
99
|
+
} finally {
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { login, logout, isAuthenticated, refreshToken } from '../../src/services/auth';
|
|
3
|
+
import * as storage from '../../src/services/storage';
|
|
4
|
+
|
|
5
|
+
describe('Auth Service', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
localStorage.clear();
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('login', () => {
|
|
12
|
+
it('should login with valid credentials', async () => {
|
|
13
|
+
const credentials = { email: 'demo@example.com', password: 'demo' };
|
|
14
|
+
const result = await login(credentials);
|
|
15
|
+
|
|
16
|
+
expect(result).toHaveProperty('token');
|
|
17
|
+
expect(result).toHaveProperty('user');
|
|
18
|
+
expect(result.user.email).toBe('demo@example.com');
|
|
19
|
+
expect(result.user.name).toBe('Demo User');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should store token and user on successful login', async () => {
|
|
23
|
+
const credentials = { email: 'demo@example.com', password: 'demo' };
|
|
24
|
+
await login(credentials);
|
|
25
|
+
|
|
26
|
+
expect(storage.getToken()).toBeTruthy();
|
|
27
|
+
expect(storage.getUser()).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should throw error for invalid credentials', async () => {
|
|
31
|
+
const credentials = { email: 'wrong@example.com', password: 'wrong' };
|
|
32
|
+
|
|
33
|
+
await expect(login(credentials)).rejects.toThrow('Invalid credentials');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should throw error for wrong password', async () => {
|
|
37
|
+
const credentials = { email: 'demo@example.com', password: 'wrong' };
|
|
38
|
+
|
|
39
|
+
await expect(login(credentials)).rejects.toThrow('Invalid credentials');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('logout', () => {
|
|
44
|
+
it('should clear token on logout', async () => {
|
|
45
|
+
storage.setToken('test-token');
|
|
46
|
+
storage.setUser({ id: '1', name: 'Test' });
|
|
47
|
+
|
|
48
|
+
await logout();
|
|
49
|
+
|
|
50
|
+
expect(storage.getToken()).toBeNull();
|
|
51
|
+
expect(storage.getUser()).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isAuthenticated', () => {
|
|
56
|
+
it('should return true when valid token exists', () => {
|
|
57
|
+
// Non-expired token
|
|
58
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJkZW1vQGV4YW1wbGUuY29tIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
|
59
|
+
storage.setToken(token);
|
|
60
|
+
|
|
61
|
+
expect(isAuthenticated()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return false when no token exists', () => {
|
|
65
|
+
expect(isAuthenticated()).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return false when token is expired', () => {
|
|
69
|
+
// Expired token
|
|
70
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjAwMDAwMDAwfQ.mock';
|
|
71
|
+
storage.setToken(token);
|
|
72
|
+
|
|
73
|
+
expect(isAuthenticated()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('refreshToken', () => {
|
|
78
|
+
it('should return new token', async () => {
|
|
79
|
+
const newToken = await refreshToken();
|
|
80
|
+
expect(newToken).toBeTruthy();
|
|
81
|
+
expect(typeof newToken).toBe('string');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should update stored token', async () => {
|
|
85
|
+
await refreshToken();
|
|
86
|
+
expect(storage.getToken()).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { decodeJWT, isTokenExpired, getTokenExpiration } from '../../src/services/jwt';
|
|
3
|
+
|
|
4
|
+
describe('JWT Service', () => {
|
|
5
|
+
describe('decodeJWT', () => {
|
|
6
|
+
it('should decode a valid JWT', () => {
|
|
7
|
+
// Valid JWT: {"sub":"123","name":"Test","exp":9999999999}
|
|
8
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiVGVzdCIsImV4cCI6OTk5OTk5OTk5OX0.mock';
|
|
9
|
+
const payload = decodeJWT(token);
|
|
10
|
+
|
|
11
|
+
expect(payload).toBeTruthy();
|
|
12
|
+
expect(payload?.sub).toBe('123');
|
|
13
|
+
expect(payload?.name).toBe('Test');
|
|
14
|
+
expect(payload?.exp).toBe(9999999999);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return null for invalid JWT format', () => {
|
|
18
|
+
const payload = decodeJWT('invalid-token');
|
|
19
|
+
expect(payload).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return null for malformed JWT', () => {
|
|
23
|
+
const payload = decodeJWT('part1.part2');
|
|
24
|
+
expect(payload).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return null for JWT with invalid base64', () => {
|
|
28
|
+
const payload = decodeJWT('header.!!!invalid!!!.signature');
|
|
29
|
+
expect(payload).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('isTokenExpired', () => {
|
|
34
|
+
it('should return false for non-expired token', () => {
|
|
35
|
+
// Token expires in year 2286
|
|
36
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
|
37
|
+
expect(isTokenExpired(token)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return true for expired token', () => {
|
|
41
|
+
// Token expired in 2020
|
|
42
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE2MDAwMDAwMDB9.mock';
|
|
43
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return true for token without exp claim', () => {
|
|
47
|
+
// Token without exp: {"sub":"123"}
|
|
48
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.mock';
|
|
49
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return true for invalid token', () => {
|
|
53
|
+
expect(isTokenExpired('invalid')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getTokenExpiration', () => {
|
|
58
|
+
it('should return expiration date for valid token', () => {
|
|
59
|
+
// Token with exp: 9999999999 (Sat Nov 20 2286)
|
|
60
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
|
61
|
+
const expiration = getTokenExpiration(token);
|
|
62
|
+
|
|
63
|
+
expect(expiration).toBeInstanceOf(Date);
|
|
64
|
+
expect(expiration?.getTime()).toBe(9999999999 * 1000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return null for token without exp', () => {
|
|
68
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.mock';
|
|
69
|
+
expect(getTokenExpiration(token)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return null for invalid token', () => {
|
|
73
|
+
expect(getTokenExpiration('invalid')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|