import { describe, expect, it, vi } from 'vitest'; import { render } from 'svelte/server'; import type { FleetServer } from '../../src/lib/fleet-overview.js'; import { fleetActionAvailable, queueFleetAction } from '../../src/lib/ui/fleet-actions.js'; import FleetActionsDialog from '../../src/lib/ui/components/FleetActionsDialog.svelte'; const now = Date.parse('2026-10-07T12:00:00Z'); const at = new Date(now - 1000).toISOString(); const server: FleetServer = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', last_gametype: 'zclassic', last_players: 4, last_seen_at: new Date(now - 10_000).toISOString(), last_error: null, container_state: 'running', container_checked_at: at, last_checked_at: at, roster: [], health: 'online', held: [], active_since: null, inactive_since: null, game_started_at: null, game_ended_at: null, host_id: 'host-1', host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: at } }; describe('fleet actions', () => { it('uses the single-server eligibility rules and permits queued destruction with unknown status', () => { expect(fleetActionAvailable(server, 'restart', now)).toBe(true); expect(fleetActionAvailable(server, 'start', now)).toBe(false); const stopped = { ...server, health: 'offline' as const, container_state: 'stopped' }; expect(fleetActionAvailable(stopped, 'start', now)).toBe(true); expect(fleetActionAvailable(stopped, 'restart', now)).toBe(false); expect(fleetActionAvailable(server, 'stop', now + 300_000)).toBe(false); expect(fleetActionAvailable({ ...server, host: null }, 'rebuild', now)).toBe(false); expect(fleetActionAvailable({ ...server, host: null }, 'destroy', now)).toBe(true); }); it.each(['restart', 'start', 'stop', 'rebuild'] as const)( 'queues %s only for selected servers and deduplicates selections', async (action) => { const request = vi .fn() .mockImplementation(async () => Response.json({ deploymentId: 'job-1' }, { status: 202 }) ); const result = await queueFleetAction( action, [server.name, 'second', server.name], request ); expect( request.mock.calls.map(([url, options]) => [ url, options.method, JSON.parse(options.body) ]) ).toEqual([ ['/api/provisioning', 'POST', { server: server.name, action }], ['/api/provisioning', 'POST', { server: 'second', action }] ]); expect(result.queued.map((item) => item.server)).toEqual([server.name, 'second']); expect(result.failed).toEqual([]); } ); it('destroys only the chosen servers through the existing per-server endpoint', async () => { const request = vi .fn() .mockResolvedValue(Response.json({ deploymentId: 'destroy-1' }, { status: 202 })); await queueFleetAction('destroy', ['server/a'], request); expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith('/api/servers/server%2Fa', { method: 'DELETE' }); }); it('reports partial failures and continues without retrying successful requests', async () => { const request = vi .fn() .mockResolvedValueOnce( Response.json({ error: 'Another job is pending.' }, { status: 409 }) ) .mockResolvedValueOnce(Response.json({ deploymentId: 'job-2' }, { status: 202 })) .mockRejectedValueOnce(new Error('offline')); const result = await queueFleetAction( 'restart', ['blocked', 'ready', 'uncertain'], request ); expect(result.queued).toEqual([{ server: 'ready', deploymentId: 'job-2' }]); expect(result.failed).toEqual([ { server: 'blocked', error: 'Another job is pending.' }, { server: 'uncertain', error: expect.stringContaining('Check Provisioning before trying again') } ]); expect(request).toHaveBeenCalledTimes(3); }); it('does not report missing job IDs as successfully queued or submit an empty selection', async () => { const request = vi.fn().mockResolvedValue(Response.json({}, { status: 202 })); expect((await queueFleetAction('stop', ['one'], request)).queued).toEqual([]); request.mockClear(); expect(await queueFleetAction('stop', [], request)).toEqual({ queued: [], failed: [] }); expect(request).not.toHaveBeenCalled(); }); it('renders a named dialog with all five actions and a disabled empty selection', () => { const body = render(FleetActionsDialog, { props: { open: true, onqueued: vi.fn() } }).body; for (const action of ['Restart', 'Start', 'Stop', 'Rebuild', 'Destroy']) expect(body).toContain(`>${action}`); expect(body).toContain('Fleet actions'); expect(body).toContain('Select all eligible'); expect(body).toContain('0 selected'); const submit = [...body.matchAll(/]*>[\s\S]*?<\/button>/g)].find((match) => match[0].replace(/\s+/g, ' ').includes('Restart 0 servers') )?.[0]; expect(submit).toContain('disabled'); }); });