1c0a7cb850
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
166 lines
4.2 KiB
TypeScript
166 lines
4.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('../../prisma.js', () => ({
|
|
prisma: {
|
|
app: {
|
|
findMany: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn()
|
|
},
|
|
appStatus: {
|
|
create: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn()
|
|
}
|
|
}
|
|
}));
|
|
|
|
import { prisma } from '../../prisma.js';
|
|
import * as appService from '../appService.js';
|
|
|
|
const mockApp = prisma.app as unknown as {
|
|
findMany: ReturnType<typeof vi.fn>;
|
|
findUnique: ReturnType<typeof vi.fn>;
|
|
create: ReturnType<typeof vi.fn>;
|
|
update: ReturnType<typeof vi.fn>;
|
|
delete: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
const mockAppStatus = prisma.appStatus as unknown as {
|
|
create: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
describe('appService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('returns all apps', async () => {
|
|
const apps = [
|
|
{ id: '1', name: 'App1', statuses: [] },
|
|
{ id: '2', name: 'App2', statuses: [] }
|
|
];
|
|
mockApp.findMany.mockResolvedValue(apps);
|
|
|
|
const result = await appService.findAll();
|
|
|
|
expect(result).toEqual(apps);
|
|
expect(mockApp.findMany).toHaveBeenCalledWith({
|
|
where: {},
|
|
orderBy: { name: 'asc' },
|
|
include: {
|
|
links: { orderBy: { order: 'asc' } },
|
|
statuses: { orderBy: { checkedAt: 'desc' }, take: 1 }
|
|
}
|
|
});
|
|
});
|
|
|
|
it('filters by category', async () => {
|
|
mockApp.findMany.mockResolvedValue([]);
|
|
|
|
await appService.findAll({ category: 'media' });
|
|
|
|
expect(mockApp.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { category: 'media' }
|
|
})
|
|
);
|
|
});
|
|
|
|
it('filters by search term', async () => {
|
|
mockApp.findMany.mockResolvedValue([]);
|
|
|
|
await appService.findAll({ search: 'grafana' });
|
|
|
|
const call = mockApp.findMany.mock.calls[0][0];
|
|
expect(call.where.OR).toBeDefined();
|
|
expect(call.where.OR).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('returns app when found', async () => {
|
|
const app = { id: '1', name: 'App', statuses: [], createdBy: null };
|
|
mockApp.findUnique.mockResolvedValue(app);
|
|
|
|
const result = await appService.findById('1');
|
|
expect(result).toEqual(app);
|
|
});
|
|
|
|
it('throws when not found', async () => {
|
|
mockApp.findUnique.mockResolvedValue(null);
|
|
await expect(appService.findById('missing')).rejects.toThrow('App not found');
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('creates app with required fields', async () => {
|
|
const input = { name: 'New App', url: 'https://app.local' };
|
|
const created = { id: '1', ...input };
|
|
mockApp.create.mockResolvedValue(created);
|
|
|
|
const result = await appService.create(input);
|
|
|
|
expect(result.id).toBe('1');
|
|
expect(mockApp.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
name: 'New App',
|
|
url: 'https://app.local',
|
|
healthcheckEnabled: false,
|
|
healthcheckInterval: 300
|
|
})
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('updates specified fields', async () => {
|
|
mockApp.findUnique.mockResolvedValue({ id: '1' });
|
|
mockApp.update.mockResolvedValue({ id: '1', name: 'Updated' });
|
|
|
|
const result = await appService.update('1', { name: 'Updated' });
|
|
|
|
expect(mockApp.update).toHaveBeenCalledWith({
|
|
where: { id: '1' },
|
|
data: { name: 'Updated' }
|
|
});
|
|
expect(result.name).toBe('Updated');
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('deletes app', async () => {
|
|
mockApp.findUnique.mockResolvedValue({ id: '1' });
|
|
mockApp.delete.mockResolvedValue({});
|
|
|
|
await appService.remove('1');
|
|
|
|
expect(mockApp.delete).toHaveBeenCalledWith({ where: { id: '1' } });
|
|
});
|
|
});
|
|
|
|
describe('recordStatus', () => {
|
|
it('creates a status record', async () => {
|
|
const status = { id: 's1', appId: '1', status: 'online', responseTime: 150 };
|
|
mockAppStatus.create.mockResolvedValue(status);
|
|
|
|
const result = await appService.recordStatus('1', 'online', 150);
|
|
|
|
expect(result).toEqual(status);
|
|
});
|
|
});
|
|
|
|
describe('getCategories', () => {
|
|
it('returns unique categories', async () => {
|
|
mockApp.findMany.mockResolvedValue([{ category: 'Media' }, { category: 'Monitoring' }]);
|
|
|
|
const result = await appService.getCategories();
|
|
|
|
expect(result).toEqual(['Media', 'Monitoring']);
|
|
});
|
|
});
|
|
});
|