feat(phase3): phase 7 - integration & polish

Fix all build/type/lint errors, write 46 new tests (222 total across
20 files), regenerate Prisma client, update seed with user preferences.
Fix SvelteSet usage, add {#each} keys, clean unused imports.
This commit is contained in:
2026-03-25 01:12:11 +03:00
parent dd6958b4d6
commit 7d8a8fb0fc
14 changed files with 1223 additions and 34 deletions
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/server/services/appService.js', () => ({
create: vi.fn()
}));
import * as appService from '$lib/server/services/appService.js';
import { POST } from '../+server.js';
const mockCreate = appService.create as ReturnType<typeof vi.fn>;
function createMockEvent(
overrides: {
user?: { id: string; role: string } | null;
body?: unknown;
jsonThrows?: boolean;
} = {}
) {
const { user = { id: 'u1', role: 'user' }, body = {}, jsonThrows = false } = overrides;
return {
locals: { user },
request: {
json: jsonThrows
? vi.fn().mockRejectedValue(new Error('Invalid JSON'))
: vi.fn().mockResolvedValue(body)
}
} as unknown as Parameters<typeof POST>[0];
}
describe('Quick-Add API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('POST /api/apps/quick-add', () => {
it('creates app with valid URL and name', async () => {
const createdApp = {
id: 'app1',
name: 'My App',
url: 'https://myapp.example.com',
icon: 'https://myapp.example.com/favicon.ico',
iconType: 'url'
};
mockCreate.mockResolvedValue(createdApp);
const response = await POST(
createMockEvent({
body: { url: 'https://myapp.example.com', name: 'My App' }
})
);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.success).toBe(true);
expect(data.data).toEqual(createdApp);
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'My App',
url: 'https://myapp.example.com',
icon: 'https://myapp.example.com/favicon.ico',
iconType: 'url',
healthcheckEnabled: true,
createdById: 'u1'
})
);
});
it('derives favicon URL from app URL', async () => {
mockCreate.mockResolvedValue({ id: 'app2' });
await POST(
createMockEvent({
body: { url: 'https://git.example.com/repos', name: 'Gitea' }
})
);
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
icon: 'https://git.example.com/favicon.ico'
})
);
});
it('rejects invalid URL', async () => {
const response = await POST(
createMockEvent({
body: { url: 'not-a-url', name: 'Bad App' }
})
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('rejects missing name', async () => {
const response = await POST(
createMockEvent({
body: { url: 'https://example.com' }
})
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('rejects non-http URLs', async () => {
const response = await POST(
createMockEvent({
body: { url: 'ftp://files.example.com', name: 'FTP Server' }
})
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('returns 400 for invalid JSON body', async () => {
const response = await POST(createMockEvent({ jsonThrows: true }));
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('returns 500 when service throws', async () => {
mockCreate.mockRejectedValue(new Error('DB error'));
const response = await POST(
createMockEvent({
body: { url: 'https://example.com', name: 'Failing App' }
})
);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.success).toBe(false);
});
it('redirects when not authenticated', async () => {
try {
await POST(createMockEvent({ user: null }));
expect.unreachable('Should have thrown redirect');
} catch (e) {
expect(e).toBeDefined();
}
});
});
});
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/server/prisma.js', () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn()
}
}
}));
import { prisma } from '$lib/server/prisma.js';
import { GET, PATCH } from '../+server.js';
const mockUser = prisma.user as unknown as {
findUnique: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function createMockEvent(
overrides: {
user?: { id: string; role: string } | null;
body?: unknown;
} = {}
) {
const { user = { id: 'u1', role: 'user' }, body = {} } = overrides;
return {
locals: { user },
request: {
json: vi.fn().mockResolvedValue(body)
}
} as unknown as Parameters<typeof GET>[0];
}
describe('User Preferences API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /api/users/me/preferences', () => {
it('returns preferences for authenticated user', async () => {
const prefs = {
themeMode: 'dark',
primaryHue: 240,
primarySaturation: 80,
backgroundType: 'none',
locale: 'en'
};
mockUser.findUnique.mockResolvedValue(prefs);
const response = await GET(createMockEvent());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.data).toEqual(prefs);
});
it('returns 404 when user not found', async () => {
mockUser.findUnique.mockResolvedValue(null);
const response = await GET(createMockEvent());
const data = await response.json();
expect(response.status).toBe(404);
expect(data.success).toBe(false);
});
it('redirects when not authenticated', async () => {
try {
await GET(createMockEvent({ user: null }));
expect.unreachable('Should have thrown redirect');
} catch (e) {
// SvelteKit redirect is thrown as an object with status and location
expect(e).toBeDefined();
}
});
});
describe('PATCH /api/users/me/preferences', () => {
it('updates theme preferences', async () => {
const updatedPrefs = {
themeMode: 'light',
primaryHue: 120,
primarySaturation: 60,
backgroundType: 'mesh',
locale: 'ru'
};
mockUser.update.mockResolvedValue(updatedPrefs);
const response = await PATCH(
createMockEvent({
body: {
themeMode: 'light',
primaryHue: 120,
primarySaturation: 60,
backgroundType: 'mesh',
locale: 'ru'
}
})
);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.data).toEqual(updatedPrefs);
});
it('rejects invalid themeMode', async () => {
const response = await PATCH(
createMockEvent({ body: { themeMode: 'invalid' } })
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toContain('themeMode');
});
it('rejects primaryHue out of range', async () => {
const response = await PATCH(
createMockEvent({ body: { primaryHue: 500 } })
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('rejects primarySaturation out of range', async () => {
const response = await PATCH(
createMockEvent({ body: { primarySaturation: -10 } })
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('rejects invalid backgroundType', async () => {
const response = await PATCH(
createMockEvent({ body: { backgroundType: 'invalid' } })
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('rejects invalid locale', async () => {
const response = await PATCH(
createMockEvent({ body: { locale: 'fr' } })
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('rejects request with no valid fields', async () => {
const response = await PATCH(
createMockEvent({ body: { unknownField: 'value' } })
);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
it('allows null values to reset preferences', async () => {
mockUser.update.mockResolvedValue({
themeMode: null,
primaryHue: null,
primarySaturation: null,
backgroundType: null,
locale: null
});
const response = await PATCH(
createMockEvent({
body: { themeMode: null, primaryHue: null }
})
);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
});
});
});