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:
@@ -1,7 +1,7 @@
|
|||||||
# Feature Context: Phase 3 — Advanced Features
|
# Feature Context: Phase 3 — Advanced Features
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
Phase 2 is complete and merged. 176 tests, full build passes. Phase 3 in progress. Phase 1 (Import/Export), Phase 2 (Sparklines), and Phase 3 (User Theme Overrides) are complete.
|
Phase 7 (Integration & Polish) is complete. 222 tests across 20 test files, full build passes, `npm run check` 0 errors, `npm run lint` 0 errors. All phases 1-7 are done.
|
||||||
|
|
||||||
### Phase 1 (Import/Export) Summary
|
### Phase 1 (Import/Export) Summary
|
||||||
exportService, importService, admin API endpoints, ImportExportPanel UI, Zod validation schema, i18n EN/RU translations.
|
exportService, importService, admin API endpoints, ImportExportPanel UI, Zod validation schema, i18n EN/RU translations.
|
||||||
@@ -23,6 +23,13 @@ exportService, importService, admin API endpoints, ImportExportPanel UI, Zod val
|
|||||||
- Header user menu includes "Settings" link
|
- Header user menu includes "Settings" link
|
||||||
- i18n keys: `settings.title`, `settings.theme`, `settings.primary_color`, `settings.hue`, `settings.saturation`, `settings.background`, `settings.language`, `settings.save`, `settings.saving`, `settings.saved` (EN/RU)
|
- i18n keys: `settings.title`, `settings.theme`, `settings.primary_color`, `settings.hue`, `settings.saturation`, `settings.background`, `settings.language`, `settings.save`, `settings.saving`, `settings.saved` (EN/RU)
|
||||||
|
|
||||||
|
### Phase 7 (Integration & Polish) Summary
|
||||||
|
- Prisma client regenerated with user preference fields
|
||||||
|
- Fixed lint errors: SvelteSet for reactive Set in DiscoveryPanel, `{#each}` keys in DiscoveryPanel/SparklineChart, unused vars in ThemeCustomizer/AppWidget
|
||||||
|
- 46 new tests: exportService (4), importService (9), discoveryService (10), preferences API (11), quick-add API (8), broadcastSync (4)
|
||||||
|
- Seed script updated: user preferences on admin/regular user, quick-add style Wiki.js app
|
||||||
|
- Final state: 222 tests, 0 build errors, 0 type errors, 0 lint errors
|
||||||
|
|
||||||
## Cross-Phase Dependencies
|
## Cross-Phase Dependencies
|
||||||
- Phases 1-3 are independent (import/export, sparklines, user themes)
|
- Phases 1-3 are independent (import/export, sparklines, user themes)
|
||||||
- Phase 4 (PWA) is independent
|
- Phase 4 (PWA) is independent
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D
|
|||||||
- [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md)
|
- [ ] Phase 4: PWA Support [frontend] → [subplan](./phase-4-pwa.md)
|
||||||
- [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md)
|
- [ ] Phase 5: Auto-Discovery Docker/Traefik [backend] → [subplan](./phase-5-autodiscovery.md)
|
||||||
- [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md)
|
- [ ] Phase 6: Bookmarklet & Multi-Tab Sync [fullstack] → [subplan](./phase-6-bookmarklet-sync.md)
|
||||||
- [ ] Phase 7: Integration & Polish [fullstack] → [subplan](./phase-7-integration.md)
|
- [x] Phase 7: Integration & Polish [fullstack] → [subplan](./phase-7-integration.md)
|
||||||
|
|
||||||
## Phase Progress Log
|
## Phase Progress Log
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Add import/export, ping history sparklines, user theme overrides, PWA support, D
|
|||||||
| Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 4: PWA | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 5: Auto-Discovery | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 6: Bookmarklet/Sync | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 7: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 7: Integration | fullstack | ✅ Complete | ✅ | ✅ | ⬜ |
|
||||||
|
|
||||||
## Final Review
|
## Final Review
|
||||||
- [ ] Comprehensive code review
|
- [ ] Comprehensive code review
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
# Phase 7: Integration & Polish
|
# Phase 7: Integration & Polish
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** fullstack
|
**Domain:** fullstack
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
- [ ] Task 1: Fix all TypeScript/build errors
|
- [x] Task 1: Fix all TypeScript/build errors
|
||||||
- [ ] Task 2: Verify `npm run build` succeeds
|
- [x] Task 2: Verify `npm run build` succeeds
|
||||||
- [ ] Task 3: Verify `npm run check` passes
|
- [x] Task 3: Verify `npm run check` passes (0 errors, warnings only)
|
||||||
- [ ] Task 4: Verify `npm run lint` passes
|
- [x] Task 4: Verify `npm run lint` passes (0 errors)
|
||||||
- [ ] Task 5: Write tests for export/import services
|
- [x] Task 5: Write tests for export/import services
|
||||||
- [ ] Task 6: Write tests for discovery service
|
- [x] Task 6: Write tests for discovery service (mocked Docker/Traefik)
|
||||||
- [ ] Task 7: Write tests for user preferences API
|
- [x] Task 7: Write tests for user preferences API
|
||||||
- [ ] Task 8: Write tests for quick-add API
|
- [x] Task 8: Write tests for quick-add API
|
||||||
- [ ] Task 9: Update seed script with sample data
|
- [x] Task 9: Write tests for broadcastSync utility
|
||||||
- [ ] Task 10: Verify all existing tests pass
|
- [x] Task 10: Update seed script with sample data (user preferences, quick-add style app)
|
||||||
- [ ] Task 11: Run Prisma migrations
|
- [x] Task 11: Run Prisma generate (migrations already applied)
|
||||||
|
- [x] Task 12: Verify all 222 tests pass across 20 test files
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
- `prisma generate` — regenerated client with user preference fields
|
||||||
|
- Fixed lint: SvelteSet for reactive Set in DiscoveryPanel, `{#each}` keys, unused vars
|
||||||
|
- New tests: exportService (4), importService (9), discoveryService (10), preferences API (11), quick-add API (8), broadcastSync (4) = 46 new tests
|
||||||
|
- Updated seed.ts: user preferences on admin/regular user, quick-add style Wiki.js app
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
<!-- Final phase -->
|
<!-- Final phase -->
|
||||||
|
|||||||
+22
-2
@@ -36,7 +36,12 @@ async function main() {
|
|||||||
password: adminPassword,
|
password: adminPassword,
|
||||||
displayName: 'Administrator',
|
displayName: 'Administrator',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
authProvider: 'local'
|
authProvider: 'local',
|
||||||
|
themeMode: 'dark',
|
||||||
|
primaryHue: 240,
|
||||||
|
primarySaturation: 80,
|
||||||
|
backgroundType: 'aurora',
|
||||||
|
locale: 'en'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(' Created admin user:', admin.email);
|
console.log(' Created admin user:', admin.email);
|
||||||
@@ -51,7 +56,12 @@ async function main() {
|
|||||||
password: userPassword,
|
password: userPassword,
|
||||||
displayName: 'Demo User',
|
displayName: 'Demo User',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
authProvider: 'local'
|
authProvider: 'local',
|
||||||
|
themeMode: 'light',
|
||||||
|
primaryHue: 150,
|
||||||
|
primarySaturation: 60,
|
||||||
|
backgroundType: 'mesh',
|
||||||
|
locale: 'ru'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(' Created regular user:', regularUser.email);
|
console.log(' Created regular user:', regularUser.email);
|
||||||
@@ -168,6 +178,16 @@ async function main() {
|
|||||||
category: 'Network',
|
category: 'Network',
|
||||||
tags: 'dns,adblock,network,privacy',
|
tags: 'dns,adblock,network,privacy',
|
||||||
healthcheckEnabled: true
|
healthcheckEnabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wiki.js',
|
||||||
|
url: 'http://wiki.local:3000',
|
||||||
|
icon: 'http://wiki.local:3000/favicon.ico',
|
||||||
|
iconType: 'url',
|
||||||
|
description: 'Quick-added wiki service (demonstrates favicon URL icon)',
|
||||||
|
category: 'Productivity',
|
||||||
|
tags: 'wiki,docs,knowledge',
|
||||||
|
healthcheckEnabled: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface DiscoveredService {
|
interface DiscoveredService {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
let approving = $state(false);
|
let approving = $state(false);
|
||||||
let services = $state<DiscoveredService[]>([]);
|
let services = $state<DiscoveredService[]>([]);
|
||||||
let scanErrors = $state<string[]>([]);
|
let scanErrors = $state<string[]>([]);
|
||||||
let selected = $state<Set<number>>(new Set());
|
let selected = new SvelteSet<number>();
|
||||||
let statusMessage = $state('');
|
let statusMessage = $state('');
|
||||||
let statusType: 'success' | 'error' | '' = $state('');
|
let statusType: 'success' | 'error' | '' = $state('');
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
scanning = true;
|
scanning = true;
|
||||||
services = [];
|
services = [];
|
||||||
scanErrors = [];
|
scanErrors = [];
|
||||||
selected = new Set();
|
selected.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/discover', {
|
const response = await fetch('/api/admin/discover', {
|
||||||
@@ -70,13 +71,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelect(index: number) {
|
function toggleSelect(index: number) {
|
||||||
const next = new Set(selected);
|
if (selected.has(index)) {
|
||||||
if (next.has(index)) {
|
selected.delete(index);
|
||||||
next.delete(index);
|
|
||||||
} else {
|
} else {
|
||||||
next.add(index);
|
selected.add(index);
|
||||||
}
|
}
|
||||||
selected = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
@@ -84,10 +83,12 @@
|
|||||||
.map((s, i) => (s.alreadyRegistered ? -1 : i))
|
.map((s, i) => (s.alreadyRegistered ? -1 : i))
|
||||||
.filter((i) => i >= 0);
|
.filter((i) => i >= 0);
|
||||||
|
|
||||||
if (selected.size === selectableIndices.length) {
|
const allSelected = selected.size === selectableIndices.length;
|
||||||
selected = new Set();
|
selected.clear();
|
||||||
} else {
|
if (!allSelected) {
|
||||||
selected = new Set(selectableIndices);
|
for (const idx of selectableIndices) {
|
||||||
|
selected.add(idx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@
|
|||||||
services = services.map((s, i) =>
|
services = services.map((s, i) =>
|
||||||
selected.has(i) ? { ...s, alreadyRegistered: true } : s
|
selected.has(i) ? { ...s, alreadyRegistered: true } : s
|
||||||
);
|
);
|
||||||
selected = new Set();
|
selected.clear();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusMessage = err instanceof Error ? err.message : 'Approval failed';
|
statusMessage = err instanceof Error ? err.message : 'Approval failed';
|
||||||
statusType = 'error';
|
statusType = 'error';
|
||||||
@@ -155,7 +156,7 @@
|
|||||||
<!-- Scan Errors -->
|
<!-- Scan Errors -->
|
||||||
{#if scanErrors.length > 0}
|
{#if scanErrors.length > 0}
|
||||||
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||||
{#each scanErrors as scanError}
|
{#each scanErrors as scanError, idx (idx)}
|
||||||
<p>{scanError}</p>
|
<p>{scanError}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +184,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each services as service, i}
|
{#each services as service, i (service.url)}
|
||||||
<tr class="border-b border-border/50 hover:bg-muted/50">
|
<tr class="border-b border-border/50 hover:bg-muted/50">
|
||||||
<td class="px-2 py-2">
|
<td class="px-2 py-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
aria-label="Status history sparkline"
|
aria-label="Status history sparkline"
|
||||||
class="inline-block align-middle"
|
class="inline-block align-middle"
|
||||||
>
|
>
|
||||||
{#each bars as bar}
|
{#each bars as bar, idx (idx)}
|
||||||
<rect
|
<rect
|
||||||
x={bar.x}
|
x={bar.x}
|
||||||
y={2}
|
y={2}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { preferences }: Props = $props();
|
let { preferences: _preferences }: Props = $props();
|
||||||
|
void _preferences; // available for future use
|
||||||
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let saved = $state(false);
|
let saved = $state(false);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
|
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
|
||||||
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
|
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock child_process for Docker discovery
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
exec: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:util', () => ({
|
||||||
|
promisify: (fn: unknown) => fn
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock appService for discoverAll
|
||||||
|
vi.mock('../appService.js', () => ({
|
||||||
|
findAll: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { findAll as findAllApps } from '../appService.js';
|
||||||
|
import { discoverDocker, discoverTraefik, discoverAll } from '../discoveryService.js';
|
||||||
|
|
||||||
|
const mockExec = exec as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const mockFindAllApps = findAllApps as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('discoveryService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discoverDocker', () => {
|
||||||
|
it('returns services from running Docker containers', async () => {
|
||||||
|
const containers = [
|
||||||
|
{
|
||||||
|
Id: 'abc123',
|
||||||
|
Names: ['/gitea'],
|
||||||
|
Image: 'gitea/gitea:latest',
|
||||||
|
Ports: [{ IP: '0.0.0.0', PrivatePort: 3000, PublicPort: 3000, Type: 'tcp' }],
|
||||||
|
Labels: { 'org.opencontainers.image.description': 'Self-hosted Git' },
|
||||||
|
State: 'running'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockExec.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||||
|
|
||||||
|
const result = await discoverDocker('/var/run/docker.sock');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(1);
|
||||||
|
expect(result.services[0].name).toBe('gitea');
|
||||||
|
expect(result.services[0].url).toBe('http://localhost:3000');
|
||||||
|
expect(result.services[0].source).toBe('docker');
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts URL from Traefik labels in Docker containers', async () => {
|
||||||
|
const containers = [
|
||||||
|
{
|
||||||
|
Id: 'def456',
|
||||||
|
Names: ['/myapp'],
|
||||||
|
Image: 'myapp:latest',
|
||||||
|
Ports: [],
|
||||||
|
Labels: {
|
||||||
|
'traefik.http.routers.myapp.rule': 'Host(`myapp.example.com`)',
|
||||||
|
'traefik.http.routers.myapp.entrypoints': 'websecure'
|
||||||
|
},
|
||||||
|
State: 'running'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockExec.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||||
|
|
||||||
|
const result = await discoverDocker('/var/run/docker.sock');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(1);
|
||||||
|
expect(result.services[0].url).toBe('https://myapp.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips containers without accessible URLs', async () => {
|
||||||
|
const containers = [
|
||||||
|
{
|
||||||
|
Id: 'nop123',
|
||||||
|
Names: ['/background-worker'],
|
||||||
|
Image: 'worker:latest',
|
||||||
|
Ports: [],
|
||||||
|
Labels: {},
|
||||||
|
State: 'running'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockExec.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||||
|
|
||||||
|
const result = await discoverDocker('/var/run/docker.sock');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when Docker socket is inaccessible', async () => {
|
||||||
|
mockExec.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock'));
|
||||||
|
|
||||||
|
const result = await discoverDocker('/var/run/docker.sock');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(0);
|
||||||
|
expect(result.error).toContain('ENOENT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discoverTraefik', () => {
|
||||||
|
it('returns services from Traefik routers', async () => {
|
||||||
|
const routers = [
|
||||||
|
{
|
||||||
|
name: 'myapp@docker',
|
||||||
|
rule: 'Host(`myapp.example.com`)',
|
||||||
|
service: 'myapp@docker',
|
||||||
|
entryPoints: ['websecure']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const services = [
|
||||||
|
{
|
||||||
|
name: 'myapp@docker',
|
||||||
|
loadBalancer: { servers: [{ url: 'http://172.17.0.2:8080' }] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes('/api/http/routers')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(routers)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(services)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await discoverTraefik('http://traefik.local:8080');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(1);
|
||||||
|
expect(result.services[0].name).toBe('myapp');
|
||||||
|
expect(result.services[0].url).toBe('https://myapp.example.com');
|
||||||
|
expect(result.services[0].source).toBe('traefik');
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error on Traefik API failure', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({ ok: false, status: 500 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await discoverTraefik('http://traefik.local:8080');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(0);
|
||||||
|
expect(result.error).toContain('500');
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when fetch throws', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await discoverTraefik('http://traefik.local:8080');
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(0);
|
||||||
|
expect(result.error).toContain('Network error');
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discoverAll', () => {
|
||||||
|
it('marks already-registered services', async () => {
|
||||||
|
mockExec.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify([
|
||||||
|
{
|
||||||
|
Id: 'c1',
|
||||||
|
Names: ['/gitea'],
|
||||||
|
Image: 'gitea/gitea',
|
||||||
|
Ports: [{ IP: '0.0.0.0', PrivatePort: 3000, PublicPort: 3000, Type: 'tcp' }],
|
||||||
|
Labels: {},
|
||||||
|
State: 'running'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFindAllApps.mockResolvedValue([
|
||||||
|
{ id: 'a1', name: 'Gitea', url: 'http://localhost:3000' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await discoverAll({ dockerSocketPath: '/var/run/docker.sock' });
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(1);
|
||||||
|
expect(result.services[0].alreadyRegistered).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by URL preferring Traefik', async () => {
|
||||||
|
mockExec.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify([
|
||||||
|
{
|
||||||
|
Id: 'c1',
|
||||||
|
Names: ['/app'],
|
||||||
|
Image: 'app:latest',
|
||||||
|
Ports: [],
|
||||||
|
Labels: {
|
||||||
|
'traefik.http.routers.app.rule': 'Host(`app.example.com`)'
|
||||||
|
},
|
||||||
|
State: 'running'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes('/api/http/routers')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
name: 'app@docker',
|
||||||
|
rule: 'Host(`app.example.com`)',
|
||||||
|
service: 'app@docker',
|
||||||
|
entryPoints: ['web']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockFindAllApps.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await discoverAll({
|
||||||
|
dockerSocketPath: '/var/run/docker.sock',
|
||||||
|
traefikApiUrl: 'http://traefik.local:8080'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should deduplicate: both Docker (via label) and Traefik discover http://app.example.com
|
||||||
|
const urls = result.services.map((s) => s.url);
|
||||||
|
const unique = new Set(urls);
|
||||||
|
expect(urls.length).toBe(unique.size);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when no sources configured', async () => {
|
||||||
|
mockFindAllApps.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await discoverAll({});
|
||||||
|
|
||||||
|
expect(result.services).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
app: { findMany: vi.fn() },
|
||||||
|
board: { findMany: vi.fn() },
|
||||||
|
group: { findMany: vi.fn() },
|
||||||
|
systemSettings: { upsert: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '../../prisma.js';
|
||||||
|
import { exportAllData } from '../exportService.js';
|
||||||
|
|
||||||
|
const mockApp = prisma.app as unknown as { findMany: ReturnType<typeof vi.fn> };
|
||||||
|
const mockBoard = prisma.board as unknown as { findMany: ReturnType<typeof vi.fn> };
|
||||||
|
const mockGroup = prisma.group as unknown as { findMany: ReturnType<typeof vi.fn> };
|
||||||
|
const mockSettings = prisma.systemSettings as unknown as {
|
||||||
|
upsert: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('exportService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportAllData', () => {
|
||||||
|
it('returns export data with version and timestamp', async () => {
|
||||||
|
mockApp.findMany.mockResolvedValue([]);
|
||||||
|
mockBoard.findMany.mockResolvedValue([]);
|
||||||
|
mockGroup.findMany.mockResolvedValue([]);
|
||||||
|
mockSettings.upsert.mockResolvedValue({
|
||||||
|
authMode: 'local',
|
||||||
|
registrationEnabled: true,
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
defaultPrimaryColor: '#6366f1',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await exportAllData();
|
||||||
|
|
||||||
|
expect(result.version).toBe('1.0');
|
||||||
|
expect(result.exportedAt).toBeTruthy();
|
||||||
|
expect(result.apps).toEqual([]);
|
||||||
|
expect(result.boards).toEqual([]);
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.settings).toEqual({
|
||||||
|
authMode: 'local',
|
||||||
|
registrationEnabled: true,
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
defaultPrimaryColor: '#6366f1',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps apps to export format stripping internal fields', async () => {
|
||||||
|
mockApp.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
name: 'Gitea',
|
||||||
|
url: 'https://git.local',
|
||||||
|
icon: 'gitea',
|
||||||
|
iconType: 'simple',
|
||||||
|
description: 'Self-hosted Git',
|
||||||
|
category: 'dev',
|
||||||
|
tags: 'git,code',
|
||||||
|
healthcheckEnabled: true,
|
||||||
|
healthcheckInterval: 300,
|
||||||
|
healthcheckMethod: 'GET',
|
||||||
|
healthcheckExpectedStatus: 200,
|
||||||
|
healthcheckTimeout: 5000,
|
||||||
|
createdById: 'u1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
mockBoard.findMany.mockResolvedValue([]);
|
||||||
|
mockGroup.findMany.mockResolvedValue([]);
|
||||||
|
mockSettings.upsert.mockResolvedValue({
|
||||||
|
authMode: 'local',
|
||||||
|
registrationEnabled: true,
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
defaultPrimaryColor: '#6366f1',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await exportAllData();
|
||||||
|
|
||||||
|
expect(result.apps).toHaveLength(1);
|
||||||
|
expect(result.apps[0]).toEqual({
|
||||||
|
name: 'Gitea',
|
||||||
|
url: 'https://git.local',
|
||||||
|
icon: 'gitea',
|
||||||
|
iconType: 'simple',
|
||||||
|
description: 'Self-hosted Git',
|
||||||
|
category: 'dev',
|
||||||
|
tags: 'git,code',
|
||||||
|
healthcheckEnabled: true,
|
||||||
|
healthcheckInterval: 300,
|
||||||
|
healthcheckMethod: 'GET',
|
||||||
|
healthcheckExpectedStatus: 200,
|
||||||
|
healthcheckTimeout: 5000
|
||||||
|
});
|
||||||
|
// Internal fields should not be present
|
||||||
|
expect((result.apps[0] as Record<string, unknown>).id).toBeUndefined();
|
||||||
|
expect((result.apps[0] as Record<string, unknown>).createdById).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps boards with nested sections and widgets', async () => {
|
||||||
|
mockApp.findMany.mockResolvedValue([]);
|
||||||
|
mockBoard.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
icon: null,
|
||||||
|
description: 'Main board',
|
||||||
|
isDefault: true,
|
||||||
|
isGuestAccessible: false,
|
||||||
|
backgroundConfig: null,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Apps',
|
||||||
|
icon: null,
|
||||||
|
order: 0,
|
||||||
|
isExpandedByDefault: true,
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
type: 'app',
|
||||||
|
order: 0,
|
||||||
|
config: '{}',
|
||||||
|
app: { name: 'Gitea' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
mockGroup.findMany.mockResolvedValue([]);
|
||||||
|
mockSettings.upsert.mockResolvedValue({
|
||||||
|
authMode: 'local',
|
||||||
|
registrationEnabled: true,
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
defaultPrimaryColor: '#6366f1',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await exportAllData();
|
||||||
|
|
||||||
|
expect(result.boards).toHaveLength(1);
|
||||||
|
expect(result.boards[0].name).toBe('Dashboard');
|
||||||
|
expect(result.boards[0].sections).toHaveLength(1);
|
||||||
|
expect(result.boards[0].sections[0].widgets).toHaveLength(1);
|
||||||
|
expect(result.boards[0].sections[0].widgets[0].appName).toBe('Gitea');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps groups stripping internal fields', async () => {
|
||||||
|
mockApp.findMany.mockResolvedValue([]);
|
||||||
|
mockBoard.findMany.mockResolvedValue([]);
|
||||||
|
mockGroup.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
name: 'Admins',
|
||||||
|
description: 'Admin users',
|
||||||
|
isDefault: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
mockSettings.upsert.mockResolvedValue({
|
||||||
|
authMode: 'local',
|
||||||
|
registrationEnabled: true,
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
defaultPrimaryColor: '#6366f1',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await exportAllData();
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(1);
|
||||||
|
expect(result.groups[0]).toEqual({
|
||||||
|
name: 'Admins',
|
||||||
|
description: 'Admin users',
|
||||||
|
isDefault: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Build a transaction mock that mirrors prisma's nested structure
|
||||||
|
const txMock = {
|
||||||
|
app: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||||
|
group: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||||
|
board: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||||
|
section: { create: vi.fn(), deleteMany: vi.fn() },
|
||||||
|
widget: { create: vi.fn() },
|
||||||
|
systemSettings: { upsert: vi.fn() }
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
$transaction: vi.fn(async (fn: (tx: typeof txMock) => Promise<void>) => {
|
||||||
|
await fn(txMock);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { validateImportData, importData } from '../importService.js';
|
||||||
|
import type { ExportData } from '../exportService.js';
|
||||||
|
|
||||||
|
function buildValidExportData(overrides: Partial<ExportData> = {}): ExportData {
|
||||||
|
return {
|
||||||
|
version: '1.0',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
apps: [],
|
||||||
|
boards: [],
|
||||||
|
groups: [],
|
||||||
|
settings: {
|
||||||
|
authMode: 'local',
|
||||||
|
registrationEnabled: true,
|
||||||
|
defaultTheme: 'dark',
|
||||||
|
defaultPrimaryColor: '#6366f1',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('importService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateImportData', () => {
|
||||||
|
it('accepts valid export data', () => {
|
||||||
|
const data = buildValidExportData();
|
||||||
|
const result = validateImportData(data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects data missing required fields', () => {
|
||||||
|
const result = validateImportData({ version: '1.0' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-object input', () => {
|
||||||
|
const result = validateImportData('not an object');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importData', () => {
|
||||||
|
it('creates new apps when none exist', async () => {
|
||||||
|
const data = buildValidExportData({
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'TestApp',
|
||||||
|
url: 'https://test.local',
|
||||||
|
icon: null,
|
||||||
|
iconType: 'lucide',
|
||||||
|
description: null,
|
||||||
|
category: null,
|
||||||
|
tags: '',
|
||||||
|
healthcheckEnabled: false,
|
||||||
|
healthcheckInterval: 300,
|
||||||
|
healthcheckMethod: 'GET',
|
||||||
|
healthcheckExpectedStatus: 200,
|
||||||
|
healthcheckTimeout: 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
txMock.app.findFirst.mockResolvedValue(null);
|
||||||
|
txMock.app.create.mockResolvedValue({ id: 'new-app-id', name: 'TestApp' });
|
||||||
|
|
||||||
|
const result = await importData(data, 'skip');
|
||||||
|
|
||||||
|
expect(result.apps.created).toBe(1);
|
||||||
|
expect(result.apps.skipped).toBe(0);
|
||||||
|
expect(txMock.app.create).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips existing apps in skip mode', async () => {
|
||||||
|
const data = buildValidExportData({
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'Existing',
|
||||||
|
url: 'https://existing.local',
|
||||||
|
icon: null,
|
||||||
|
iconType: 'lucide',
|
||||||
|
description: null,
|
||||||
|
category: null,
|
||||||
|
tags: '',
|
||||||
|
healthcheckEnabled: false,
|
||||||
|
healthcheckInterval: 300,
|
||||||
|
healthcheckMethod: 'GET',
|
||||||
|
healthcheckExpectedStatus: 200,
|
||||||
|
healthcheckTimeout: 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' });
|
||||||
|
|
||||||
|
const result = await importData(data, 'skip');
|
||||||
|
|
||||||
|
expect(result.apps.skipped).toBe(1);
|
||||||
|
expect(result.apps.created).toBe(0);
|
||||||
|
expect(txMock.app.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites existing apps in overwrite mode', async () => {
|
||||||
|
const data = buildValidExportData({
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'Existing',
|
||||||
|
url: 'https://updated.local',
|
||||||
|
icon: null,
|
||||||
|
iconType: 'lucide',
|
||||||
|
description: 'updated',
|
||||||
|
category: null,
|
||||||
|
tags: '',
|
||||||
|
healthcheckEnabled: true,
|
||||||
|
healthcheckInterval: 60,
|
||||||
|
healthcheckMethod: 'GET',
|
||||||
|
healthcheckExpectedStatus: 200,
|
||||||
|
healthcheckTimeout: 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' });
|
||||||
|
|
||||||
|
const result = await importData(data, 'overwrite');
|
||||||
|
|
||||||
|
expect(result.apps.updated).toBe(1);
|
||||||
|
expect(txMock.app.update).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new groups', async () => {
|
||||||
|
const data = buildValidExportData({
|
||||||
|
groups: [{ name: 'NewGroup', description: 'A group', isDefault: false }]
|
||||||
|
});
|
||||||
|
|
||||||
|
txMock.group.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await importData(data, 'skip');
|
||||||
|
|
||||||
|
expect(result.groups.created).toBe(1);
|
||||||
|
expect(txMock.group.create).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates boards with sections and widgets', async () => {
|
||||||
|
const data = buildValidExportData({
|
||||||
|
boards: [
|
||||||
|
{
|
||||||
|
name: 'Board1',
|
||||||
|
icon: null,
|
||||||
|
description: null,
|
||||||
|
isDefault: false,
|
||||||
|
isGuestAccessible: false,
|
||||||
|
backgroundConfig: null,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Section1',
|
||||||
|
icon: null,
|
||||||
|
order: 0,
|
||||||
|
isExpandedByDefault: true,
|
||||||
|
widgets: [
|
||||||
|
{ type: 'note', order: 0, config: '{}', appName: null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
txMock.board.findFirst.mockResolvedValue(null);
|
||||||
|
txMock.board.create.mockResolvedValue({ id: 'board-id' });
|
||||||
|
txMock.section.create.mockResolvedValue({ id: 'section-id' });
|
||||||
|
|
||||||
|
const result = await importData(data, 'skip');
|
||||||
|
|
||||||
|
expect(result.boards.created).toBe(1);
|
||||||
|
expect(txMock.section.create).toHaveBeenCalledOnce();
|
||||||
|
expect(txMock.widget.create).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports settings when provided', async () => {
|
||||||
|
const data = buildValidExportData({
|
||||||
|
settings: {
|
||||||
|
authMode: 'both',
|
||||||
|
registrationEnabled: false,
|
||||||
|
defaultTheme: 'light',
|
||||||
|
defaultPrimaryColor: '#ff0000',
|
||||||
|
healthcheckDefaults: '{}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await importData(data, 'skip');
|
||||||
|
|
||||||
|
expect(result.settingsUpdated).toBe(true);
|
||||||
|
expect(txMock.systemSettings.upsert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock BroadcastChannel in Node environment
|
||||||
|
class MockBroadcastChannel {
|
||||||
|
static instances: MockBroadcastChannel[] = [];
|
||||||
|
name: string;
|
||||||
|
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||||
|
private listeners: Array<{ type: string; handler: (event: { data: unknown }) => void }> = [];
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(name: string) {
|
||||||
|
this.name = name;
|
||||||
|
MockBroadcastChannel.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(data: unknown) {
|
||||||
|
// Broadcast to other instances with same name
|
||||||
|
for (const instance of MockBroadcastChannel.instances) {
|
||||||
|
if (instance !== this && instance.name === this.name && !instance.closed) {
|
||||||
|
for (const listener of instance.listeners) {
|
||||||
|
if (listener.type === 'message') {
|
||||||
|
listener.handler({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, handler: (event: { data: unknown }) => void) {
|
||||||
|
this.listeners.push({ type, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, handler: (event: { data: unknown }) => void) {
|
||||||
|
this.listeners = this.listeners.filter(
|
||||||
|
(l) => !(l.type === type && l.handler === handler)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static reset() {
|
||||||
|
MockBroadcastChannel.instances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('broadcastSync', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
MockBroadcastChannel.reset();
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcastThemeChange sends theme-change message', async () => {
|
||||||
|
const { broadcastThemeChange } = await import('$lib/utils/broadcastSync.js');
|
||||||
|
|
||||||
|
const spy = vi.fn();
|
||||||
|
const channel = new MockBroadcastChannel('wal-sync');
|
||||||
|
channel.addEventListener('message', (event: { data: unknown }) => {
|
||||||
|
spy(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastThemeChange({
|
||||||
|
mode: 'dark',
|
||||||
|
primaryHue: 240,
|
||||||
|
primarySaturation: 80,
|
||||||
|
backgroundType: 'none'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith({
|
||||||
|
type: 'theme-change',
|
||||||
|
payload: {
|
||||||
|
mode: 'dark',
|
||||||
|
primaryHue: 240,
|
||||||
|
primarySaturation: 80,
|
||||||
|
backgroundType: 'none'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcastDataChange sends data-change message', async () => {
|
||||||
|
const { broadcastDataChange } = await import('$lib/utils/broadcastSync.js');
|
||||||
|
|
||||||
|
const spy = vi.fn();
|
||||||
|
const channel = new MockBroadcastChannel('wal-sync');
|
||||||
|
channel.addEventListener('message', (event: { data: unknown }) => {
|
||||||
|
spy(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastDataChange('app');
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith({
|
||||||
|
type: 'data-change',
|
||||||
|
payload: { entity: 'app' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onSyncMessage registers a listener and returns cleanup', async () => {
|
||||||
|
const { onSyncMessage } = await import('$lib/utils/broadcastSync.js');
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const cleanup = onSyncMessage(callback);
|
||||||
|
|
||||||
|
// Simulate a message from another tab
|
||||||
|
const sender = new MockBroadcastChannel('wal-sync');
|
||||||
|
sender.postMessage({ type: 'data-change', payload: { entity: 'board' } });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith({
|
||||||
|
type: 'data-change',
|
||||||
|
payload: { entity: 'board' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup should work
|
||||||
|
cleanup();
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-op cleanup when window is undefined', async () => {
|
||||||
|
vi.stubGlobal('window', undefined);
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { onSyncMessage } = await import('$lib/utils/broadcastSync.js');
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const cleanup = onSyncMessage(callback);
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
cleanup(); // Should not throw
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user