import { describe, it, expect, beforeEach } from 'vitest' import request from 'supertest' import { app } from '../server/server.js' import { _resetForTest } from '../server/db.js' beforeEach(() => _resetForTest()) const base = { name: 'traefik', vmid: 100, state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0, } // ── GET /api/instances ──────────────────────────────────────────────────────── describe('GET /api/instances', () => { it('returns empty array when no instances exist', async () => { const res = await request(app).get('/api/instances') expect(res.status).toBe(200) expect(res.body).toEqual([]) }) it('returns all instances sorted by name', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'zebra' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'alpha' }) const res = await request(app).get('/api/instances') expect(res.status).toBe(200) expect(res.body).toHaveLength(2) expect(res.body[0].name).toBe('alpha') expect(res.body[1].name).toBe('zebra') }) it('filters by state', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', state: 'deployed' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', state: 'degraded' }) const res = await request(app).get('/api/instances?state=deployed') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('a') }) it('filters by stack', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', stack: 'production' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', stack: 'development', state: 'testing' }) const res = await request(app).get('/api/instances?stack=development') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('b') }) it('searches by name substring', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'plex' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'gitea' }) const res = await request(app).get('/api/instances?search=ple') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('plex') }) it('searches by vmid', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 137, name: 'a' }) await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' }) const res = await request(app).get('/api/instances?search=137') expect(res.body).toHaveLength(1) expect(res.body[0].vmid).toBe(137) }) it('combines search and state filters', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'plex', state: 'deployed' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'plex2', state: 'degraded' }) const res = await request(app).get('/api/instances?search=plex&state=deployed') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('plex') }) }) // ── GET /api/instances/stacks ───────────────────────────────────────────────── describe('GET /api/instances/stacks', () => { it('returns empty array when no instances exist', async () => { const res = await request(app).get('/api/instances/stacks') expect(res.status).toBe(200) expect(res.body).toEqual([]) }) it('returns unique stacks sorted alphabetically', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', stack: 'production' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', stack: 'development', state: 'testing' }) await request(app).post('/api/instances').send({ ...base, vmid: 3, name: 'c', stack: 'production' }) const res = await request(app).get('/api/instances/stacks') expect(res.body).toEqual(['development', 'production']) }) }) // ── GET /api/instances/:vmid ────────────────────────────────────────────────── describe('GET /api/instances/:vmid', () => { it('returns the instance for a known vmid', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 117, name: 'plex' }) const res = await request(app).get('/api/instances/117') expect(res.status).toBe(200) expect(res.body.name).toBe('plex') expect(res.body.vmid).toBe(117) }) it('returns 404 for unknown vmid', async () => { const res = await request(app).get('/api/instances/999') expect(res.status).toBe(404) expect(res.body.error).toBeDefined() }) it('returns 400 for non-numeric vmid', async () => { const res = await request(app).get('/api/instances/abc') expect(res.status).toBe(400) }) }) // ── POST /api/instances ─────────────────────────────────────────────────────── describe('POST /api/instances', () => { it('creates an instance and returns 201 with the created record', async () => { const res = await request(app).post('/api/instances').send(base) expect(res.status).toBe(201) expect(res.body.name).toBe('traefik') expect(res.body.vmid).toBe(100) expect(res.body.created_at).not.toBeNull() expect(res.body.updated_at).not.toBeNull() }) it('stores service flags correctly', async () => { const res = await request(app).post('/api/instances').send({ ...base, atlas: 1, tailscale: 1, hardware_acceleration: 1 }) expect(res.body.atlas).toBe(1) expect(res.body.tailscale).toBe(1) expect(res.body.hardware_acceleration).toBe(1) expect(res.body.argus).toBe(0) }) it('returns 409 for duplicate vmid', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).post('/api/instances').send({ ...base, name: 'other' }) expect(res.status).toBe(409) expect(res.body.error).toMatch(/vmid/) }) it('returns 400 when name is missing', async () => { const res = await request(app).post('/api/instances').send({ ...base, name: '' }) expect(res.status).toBe(400) expect(res.body.errors).toBeInstanceOf(Array) }) it('returns 400 for vmid less than 1', async () => { const res = await request(app).post('/api/instances').send({ ...base, vmid: 0 }) expect(res.status).toBe(400) expect(res.body.errors).toBeInstanceOf(Array) }) it('returns 400 for invalid state', async () => { const res = await request(app).post('/api/instances').send({ ...base, state: 'invalid' }) expect(res.status).toBe(400) }) it('returns 400 for invalid stack', async () => { const res = await request(app).post('/api/instances').send({ ...base, stack: 'invalid' }) expect(res.status).toBe(400) }) it('trims whitespace from name', async () => { const res = await request(app).post('/api/instances').send({ ...base, name: ' plex ' }) expect(res.status).toBe(201) expect(res.body.name).toBe('plex') }) }) // ── PUT /api/instances/:vmid ────────────────────────────────────────────────── describe('PUT /api/instances/:vmid', () => { it('updates fields and returns the updated record', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).put('/api/instances/100').send({ ...base, name: 'updated', state: 'degraded' }) expect(res.status).toBe(200) expect(res.body.name).toBe('updated') expect(res.body.state).toBe('degraded') }) it('can change the vmid', async () => { await request(app).post('/api/instances').send(base) await request(app).put('/api/instances/100').send({ ...base, vmid: 200 }) expect((await request(app).get('/api/instances/100')).status).toBe(404) expect((await request(app).get('/api/instances/200')).status).toBe(200) }) it('returns 404 for unknown vmid', async () => { const res = await request(app).put('/api/instances/999').send(base) expect(res.status).toBe(404) }) it('returns 400 for validation errors', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).put('/api/instances/100').send({ ...base, name: '' }) expect(res.status).toBe(400) expect(res.body.errors).toBeInstanceOf(Array) }) it('returns 409 when new vmid conflicts with an existing instance', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 100, name: 'a' }) await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' }) const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 }) expect(res.status).toBe(409) }) }) // ── DELETE /api/instances/:vmid ─────────────────────────────────────────────── describe('DELETE /api/instances/:vmid', () => { it('deletes a development instance and returns 204', async () => { await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' }) const res = await request(app).delete('/api/instances/100') expect(res.status).toBe(204) expect((await request(app).get('/api/instances/100')).status).toBe(404) }) it('returns 422 when attempting to delete a production instance', async () => { await request(app).post('/api/instances').send({ ...base, stack: 'production' }) const res = await request(app).delete('/api/instances/100') expect(res.status).toBe(422) expect(res.body.error).toMatch(/development/) }) it('returns 404 for unknown vmid', async () => { const res = await request(app).delete('/api/instances/999') expect(res.status).toBe(404) }) it('returns 400 for non-numeric vmid', async () => { const res = await request(app).delete('/api/instances/abc') expect(res.status).toBe(400) }) })