import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ERROR_CODES } from '@insforge/shared-schemas'; import type { AppError } from '@/utils/errors.js'; // --- Mocks --- const mockQuery = vi.fn(); const mockPool = { query: mockQuery }; vi.mock('@/infra/database/database.manager.js', () => ({ DatabaseManager: { getInstance: () => ({ getPool: () => mockPool, }), }, })); vi.mock('@/infra/security/encryption.manager.js', () => ({ EncryptionManager: { encrypt: vi.fn((v: string) => `encrypted:${v}`), decrypt: vi.fn((v: string) => v.replace('encrypted:', '')), }, })); vi.mock('@/infra/config/app.config.js', () => { const c = { fly: { enabled: true, apiToken: 'test-token', org: 'test-org', domain: 'fly.dev', }, cloud: { projectId: '', apiHost: '', }, app: { jwtSecret: 'test-secret', }, storage: { appKey: 'testkey1', }, }; return { config: c, appConfig: c, }; }); vi.mock('@/utils/logger.js', () => ({ default: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); const mockCreateApp = vi.fn(); const mockDestroyApp = vi.fn(); const mockLaunchMachine = vi.fn(); const mockUpdateMachine = vi.fn(); const mockStopMachine = vi.fn(); const mockStartMachine = vi.fn(); const mockDestroyMachine = vi.fn(); const mockGetEvents = vi.fn(); const mockGetLogs = vi.fn(); const mockListMachines = vi.fn(); const mockIsConfigured = vi.fn(() => true); const mockFlyInstance = { createApp: mockCreateApp, destroyApp: mockDestroyApp, launchMachine: mockLaunchMachine, updateMachine: mockUpdateMachine, stopMachine: mockStopMachine, startMachine: mockStartMachine, destroyMachine: mockDestroyMachine, getEvents: mockGetEvents, getLogs: mockGetLogs, listMachines: mockListMachines, isConfigured: mockIsConfigured, }; vi.mock('@/providers/compute/fly.provider.js', () => ({ FlyProvider: { getInstance: () => mockFlyInstance, }, })); import { ComputeServicesService } from '@/services/compute/services.service.js'; import { MachineGoneError } from '@/providers/compute/compute.provider.js'; describe('ComputeServicesService', () => { let service: ComputeServicesService; const oldEnvAppKey = process.env.APP_KEY; beforeEach(() => { vi.resetAllMocks(); process.env.APP_KEY = 'testkey1'; service = ComputeServicesService.getInstance(); mockIsConfigured.mockReturnValue(true); }); afterEach(() => { if (oldEnvAppKey === undefined) { delete process.env.APP_KEY; } else { process.env.APP_KEY = oldEnvAppKey; } }); describe('createService', () => { const input = { projectId: 'proj-123', name: 'my-api', imageUrl: 'docker.io/myapp:latest', port: 8080, cpu: 'shared-1x' as const, memory: 256, region: 'iad', envVars: { NODE_ENV: 'production' }, }; it('inserts into DB, calls createApp + launchMachine, updates status to running', async () => { const serviceId = 'svc-uuid-1'; // INSERT returns the new row mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockResolvedValue({ appId: 'my-api-proj-123' }); mockLaunchMachine.mockResolvedValue({ machineId: 'machine-abc' }); // UPDATE after deploy mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: 'machine-abc', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); const result = await service.createService(input); // Verify INSERT was called expect(mockQuery).toHaveBeenCalledTimes(2); const insertCall = mockQuery.mock.calls[0]; expect(insertCall[0]).toContain('INSERT INTO compute.services'); expect(insertCall[1]).toContain(input.projectId); expect(insertCall[1]).toContain(input.name); // Verify Fly calls expect(mockCreateApp).toHaveBeenCalledWith({ name: 'my-api-proj-123', network: 'n-testkey1', org: 'test-org', }); expect(mockLaunchMachine).toHaveBeenCalledWith( expect.objectContaining({ appId: 'my-api-proj-123', image: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, }) ); // Verify status update const updateCall = mockQuery.mock.calls[1]; expect(updateCall[0]).toContain('UPDATE compute.services'); expect(updateCall[1]).toContain('running'); // Verify returned shape is camelCase expect(result.id).toBe(serviceId); expect(result.projectId).toBe(input.projectId); expect(result.status).toBe('running'); expect(result.flyAppId).toBe('my-api-proj-123'); expect(result.flyMachineId).toBe('machine-abc'); expect(result.endpointUrl).toBe('https://my-api-proj-123.fly.dev'); }); it('throws COMPUTE_SERVICE_NOT_CONFIGURED when provider is not configured', async () => { mockIsConfigured.mockReturnValue(false); await expect(service.createService(input)).rejects.toThrow( 'Compute services are not enabled on this project.' ); }); it('sets status to failed when Fly deploy fails', async () => { const serviceId = 'svc-uuid-2'; // INSERT mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockRejectedValue(new Error('Fly API error')); // UPDATE to failed status mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); await expect(service.createService(input)).rejects.toThrow(); // Verify status was set to 'failed' const failedUpdateCall = mockQuery.mock.calls[1]; expect(failedUpdateCall[0]).toContain('UPDATE compute.services'); expect(failedUpdateCall[1]).toContain('failed'); }); // INS-271: end-to-end pass-through of `protocol: 'tcp'` from createService // input → INSERT column list → launchMachine params. The cloud-backend + // CLI work is wasted if the OSS strips this field. Pin both wire surfaces. it('forwards protocol: tcp to INSERT and launchMachine when supplied', async () => { const serviceId = 'svc-tcp-create'; const tcpInput = { ...input, protocol: 'tcp' as const, port: 6379, name: 'my-redis' }; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: tcpInput.projectId, name: tcpInput.name, image_url: tcpInput.imageUrl, port: tcpInput.port, cpu: tcpInput.cpu, memory: tcpInput.memory, region: tcpInput.region, protocol: 'tcp', fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockResolvedValue({ appId: 'my-redis-proj-123' }); mockLaunchMachine.mockResolvedValue({ machineId: 'mach-tcp' }); mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: tcpInput.projectId, name: tcpInput.name, image_url: tcpInput.imageUrl, port: tcpInput.port, cpu: tcpInput.cpu, memory: tcpInput.memory, region: tcpInput.region, protocol: 'tcp', fly_app_id: 'my-redis-proj-123', fly_machine_id: 'mach-tcp', status: 'running', endpoint_url: 'https://my-redis-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); const result = await service.createService(tcpInput); // INSERT must include the column AND the value (positional bind, so // assert the value made it into the params list rather than parsing the // SQL string). const insertCall = mockQuery.mock.calls[0]; expect(insertCall[0]).toContain('protocol'); expect(insertCall[1]).toContain('tcp'); // launchMachine must receive protocol: 'tcp' so the provider can pick // raw-TCP edge handlers instead of HTTP. expect(mockLaunchMachine).toHaveBeenCalledWith(expect.objectContaining({ protocol: 'tcp' })); // Response shape echoes the persisted value (verified via mapRowToSchema). expect(result.protocol).toBe('tcp'); }); // Back-compat — omitting protocol must NOT inject 'http' into the // launchMachine call as a positive value. Sending `protocol: undefined` is // fine (JSON.stringify drops it on the wire); sending `protocol: 'http'` // would change the wire format vs pre-INS-271 deploys, which we don't // want until we also rev the cloud-backend's wire-format invariant. it('omitting protocol does not inject a default value into launchMachine', async () => { const serviceId = 'svc-no-proto'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockResolvedValue({ appId: 'my-api-proj-123' }); mockLaunchMachine.mockResolvedValue({ machineId: 'mach-default' }); mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: 'mach-default', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); await service.createService(input); // input has no `protocol` // launchMachine was called; protocol param is undefined (the provider // applies its own default). JSON.stringify drops undefined fields, so // this preserves the pre-INS-271 wire format byte-for-byte. const launchCall = mockLaunchMachine.mock.calls[0][0]; expect(launchCall.protocol).toBeUndefined(); }); // --always-on: end-to-end pass-through of `scaleToZero: false` from // createService input → INSERT column list → launchMachine params, same // shape as the protocol pass-through above. it('forwards scaleToZero: false to INSERT and launchMachine when supplied', async () => { const serviceId = 'svc-always-on'; const alwaysOnInput = { ...input, scaleToZero: false, name: 'my-always-on' }; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: alwaysOnInput.projectId, name: alwaysOnInput.name, image_url: alwaysOnInput.imageUrl, port: alwaysOnInput.port, cpu: alwaysOnInput.cpu, memory: alwaysOnInput.memory, region: alwaysOnInput.region, protocol: 'http', scale_to_zero: false, fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockResolvedValue({ appId: 'my-always-on-proj-123' }); mockLaunchMachine.mockResolvedValue({ machineId: 'mach-always-on' }); mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: alwaysOnInput.projectId, name: alwaysOnInput.name, image_url: alwaysOnInput.imageUrl, port: alwaysOnInput.port, cpu: alwaysOnInput.cpu, memory: alwaysOnInput.memory, region: alwaysOnInput.region, protocol: 'http', scale_to_zero: false, fly_app_id: 'my-always-on-proj-123', fly_machine_id: 'mach-always-on', status: 'running', endpoint_url: 'https://my-always-on-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); const result = await service.createService(alwaysOnInput); // INSERT must include the column AND the value (positional bind). const insertCall = mockQuery.mock.calls[0]; expect(insertCall[0]).toContain('scale_to_zero'); expect(insertCall[1]).toContain(false); // launchMachine must receive scaleToZero: false so the provider flips // the Fly autostop block to always-on. expect(mockLaunchMachine).toHaveBeenCalledWith( expect.objectContaining({ scaleToZero: false }) ); // Response shape echoes the persisted value (via mapRowToSchema). expect(result.scaleToZero).toBe(false); }); // Back-compat — omitting scaleToZero persists the default (true) but must // NOT inject a positive value into the launchMachine call; the provider // applies its own scale-to-zero default. it('omitting scaleToZero persists true and does not inject a value into launchMachine', async () => { const serviceId = 'svc-default-stz'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockResolvedValue({ appId: 'my-api-proj-123' }); mockLaunchMachine.mockResolvedValue({ machineId: 'mach-default-stz' }); mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: 'mach-default-stz', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); await service.createService(input); // input has no `scaleToZero` // INSERT falls back to true (`input.scaleToZero ?? true`). const insertCall = mockQuery.mock.calls[0]; expect(insertCall[0]).toContain('scale_to_zero'); expect(insertCall[1]).toContain(true); const launchCall = mockLaunchMachine.mock.calls[0][0]; expect(launchCall.scaleToZero).toBeUndefined(); }); it('passes through structured cloud errors (quota, invalid input, etc.) instead of swallowing as generic 502', async () => { // Reproduces the bug found by stress-testing against staging: // when the cloud backend returns 403 COMPUTE_QUOTA_EXCEEDED with a clear // message ("Project X has reached 5 active services"), the OSS was // catching it and re-throwing as generic // "Compute service operation failed" 502 — losing the actual reason. const { AppError } = await import('@/utils/errors.js'); const serviceId = 'svc-quota-test'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: null, fly_machine_id: null, status: 'creating', endpoint_url: null, env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); // CloudComputeProvider re-wraps the cloud's response body verbatim into // an AppError; replicate that shape — JSON string in `message`, status // code = HTTP status from cloud. const cloudQuotaError = new AppError( JSON.stringify({ code: ERROR_CODES.COMPUTE_QUOTA_EXCEEDED, error: 'Project e8a6b768 has reached 5 active services', }), 403, ERROR_CODES.COMPUTE_PROVIDER_ERROR ); mockCreateApp.mockRejectedValue(cloudQuotaError); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); let thrown: AppError | undefined; try { await service.createService(input); } catch (e) { thrown = e as AppError; } expect(thrown).toBeDefined(); // Real bug: this used to be 502/COMPUTE_SERVICE_DEPLOY_FAILED. Should be // the cloud's actual code + message + status. expect(thrown!.statusCode).toBe(403); expect(thrown!.code).toBe(ERROR_CODES.COMPUTE_QUOTA_EXCEEDED); expect(thrown!.message).toMatch(/has reached 5 active services/); }); }); describe('listServices', () => { it('queries with project_id and returns camelCase rows', async () => { const projectId = 'proj-123'; mockQuery.mockResolvedValueOnce({ rows: [ { id: 'svc-1', project_id: projectId, name: 'app-one', image_url: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'app-one-proj-123', fly_machine_id: 'machine-1', status: 'running', endpoint_url: 'https://app-one-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); const results = await service.listServices(projectId); expect(mockQuery).toHaveBeenCalledTimes(1); const call = mockQuery.mock.calls[0]; expect(call[0]).toContain('compute.services'); expect(call[1]).toEqual([projectId]); expect(results).toHaveLength(1); expect(results[0].projectId).toBe(projectId); expect(results[0].flyAppId).toBe('app-one-proj-123'); }); }); describe('deleteService', () => { it('tolerates a cloud 404 (JSON body without a "404" substring) from destroyApp', async () => { const serviceId = 'svc-delete-gone'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: 'proj-123', name: 'app-gone', image_url: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'app-gone-proj-123', fly_machine_id: 'machine-gone-1', status: 'stopped', endpoint_url: 'https://app-gone-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); const { AppError: RealAppError } = await import('@/utils/errors.js'); // Cloud AppError message is the raw JSON body — no '404' substring. const cloud404 = new RealAppError( '{"code":"COMPUTE_SERVICE_NOT_FOUND","error":"app not found in project"}', 404, ERROR_CODES.COMPUTE_PROVIDER_ERROR ); mockDestroyMachine.mockRejectedValue(cloud404); mockDestroyApp.mockRejectedValue(cloud404); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // destroying UPDATE mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // DELETE const snapshot = await service.deleteService(serviceId); expect(snapshot.id).toBe(serviceId); const deleteCall = mockQuery.mock.calls[mockQuery.mock.calls.length - 1]; expect(deleteCall[0]).toContain('DELETE FROM compute.services'); }); it('marks as destroying, destroys Fly resources, and deletes from DB', async () => { const serviceId = 'svc-delete-1'; // getService query mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: 'proj-123', name: 'app-del', image_url: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'app-del-proj-123', fly_machine_id: 'machine-del', status: 'running', endpoint_url: 'https://app-del-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockDestroyMachine.mockResolvedValue(undefined); mockDestroyApp.mockResolvedValue(undefined); // UPDATE (destroying) + DELETE queries mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); const snapshot = await service.deleteService(serviceId); expect(mockDestroyMachine).toHaveBeenCalledWith('app-del-proj-123', 'machine-del'); expect(mockDestroyApp).toHaveBeenCalledWith('app-del-proj-123'); // First DB call after getService is the status update to 'destroying' const destroyingCall = mockQuery.mock.calls[1]; expect(destroyingCall[0]).toContain('destroying'); // Last DB call is the DELETE const deleteCall = mockQuery.mock.calls[mockQuery.mock.calls.length - 1]; expect(deleteCall[0]).toContain('DELETE FROM compute.services'); expect(deleteCall[1]).toEqual([serviceId]); // Snapshot is enough state to reconstruct the deleted service (used as // an audit-log paper trail for accidental deletes). expect(snapshot).toEqual({ id: serviceId, projectId: 'proj-123', name: 'app-del', imageUrl: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', // mapRowToSchema / snapshot path falls back to 'http' when the row is // missing the column (pre-INS-271 rows backfilled by the 047 // migration). The fixture above doesn't set `protocol`, so this is // the back-compat default surfacing. protocol: 'http', // Same back-compat fallback: fixture has no scale_to_zero column, so // the snapshot surfaces the default (true). scaleToZero: true, flyAppId: 'app-del-proj-123', flyMachineId: 'machine-del', endpointUrl: 'https://app-del-proj-123.fly.dev', envVarsEncrypted: null, createdAt: '2026-01-01T00:00:00Z', }); }); it('snapshot passes the env_vars_encrypted ciphertext through verbatim — never decrypts', async () => { const serviceId = 'svc-delete-env'; // Use a deliberately opaque blob with no plaintext substring inside. // Production stores AES-GCM ciphertext; the assertion is that the // service hands the bytes through to the caller unchanged, never // attempting to decrypt them on the delete path. const ciphertext = 'AESGCM:opaque-cipher-blob-xyz'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: 'proj-123', name: 'app-env', image_url: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'app-env-proj-123', fly_machine_id: 'machine-env', status: 'running', endpoint_url: 'https://app-env-proj-123.fly.dev', env_vars_encrypted: ciphertext, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockDestroyMachine.mockResolvedValue(undefined); mockDestroyApp.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // UPDATE destroying mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // DELETE const snapshot = await service.deleteService(serviceId); expect(snapshot.envVarsEncrypted).toBe(ciphertext); }); it('marks as failed and throws if Fly destroy fails (preserves DB reference)', async () => { const serviceId = 'svc-delete-2'; // getService query mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: 'proj-123', name: 'app-del2', image_url: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'app-del2-proj-123', fly_machine_id: 'machine-del2', status: 'running', endpoint_url: 'https://app-del2-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockDestroyMachine.mockRejectedValue(new Error('Fly error')); // UPDATE (destroying) + UPDATE (failed) queries mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); await expect(service.deleteService(serviceId)).rejects.toThrow( 'Failed to delete compute service' ); // DB row should be preserved (marked failed, not deleted) const failedCall = mockQuery.mock.calls[2]; expect(failedCall[0]).toContain('failed'); }); }); describe('prepareForDeploy', () => { const input = { projectId: 'proj-123', name: 'my-api', imageUrl: 'dockerfile', port: 8080, cpu: 'shared-1x' as const, memory: 512, region: 'iad', }; it('inserts DB record with deploying status and creates Fly app (no machine)', async () => { const serviceId = 'svc-deploy-1'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: null, status: 'deploying', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockResolvedValue({ appId: 'my-api-proj-123' }); const result = await service.prepareForDeploy(input); // Verify INSERT const insertCall = mockQuery.mock.calls[0]; expect(insertCall[0]).toContain('INSERT INTO compute.services'); expect(insertCall[0]).toContain("'deploying'"); expect(insertCall[1]).toContain(input.projectId); expect(insertCall[1]).toContain('my-api-proj-123'); // flyAppId // Verify Fly app created expect(mockCreateApp).toHaveBeenCalledWith({ name: 'my-api-proj-123', network: 'n-testkey1', org: 'test-org', }); // Verify NO machine launched expect(mockLaunchMachine).not.toHaveBeenCalled(); // Verify returned shape expect(result.id).toBe(serviceId); expect(result.status).toBe('deploying'); expect(result.flyAppId).toBe('my-api-proj-123'); expect(result.flyMachineId).toBeNull(); expect(result.endpointUrl).toBe('https://my-api-proj-123.fly.dev'); }); it('throws COMPUTE_SERVICE_NOT_CONFIGURED when provider is not configured', async () => { mockIsConfigured.mockReturnValue(false); await expect(service.prepareForDeploy(input)).rejects.toThrow( 'Compute services are not enabled on this project.' ); }); it('ignores 422 error from createApp (app already exists)', async () => { const serviceId = 'svc-deploy-2'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: null, status: 'deploying', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockRejectedValue(new Error('Fly API error (422): app already exists')); const result = await service.prepareForDeploy(input); expect(result.id).toBe(serviceId); expect(result.status).toBe('deploying'); }); it('cleans up DB record and rethrows on non-422 Fly error', async () => { const serviceId = 'svc-deploy-3'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: null, status: 'deploying', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockRejectedValue(new Error('Fly API error (500): internal error')); mockDestroyApp.mockResolvedValue(undefined); // DELETE cleanup mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); await expect(service.prepareForDeploy(input)).rejects.toThrow('Fly API error (500)'); // Verify Fly-side cleanup so the partially-created app doesn't leak // (regression: previously only the DB row was deleted, leaving the // Fly app orphaned in our org). expect(mockDestroyApp).toHaveBeenCalledWith('my-api-proj-123'); // Verify cleanup DELETE const deleteCall = mockQuery.mock.calls[1]; expect(deleteCall[0]).toContain('DELETE FROM compute.services'); expect(deleteCall[1]).toEqual([serviceId]); }); it('still rethrows the original Fly error if destroyApp cleanup itself fails', async () => { const serviceId = 'svc-deploy-4'; mockQuery.mockResolvedValueOnce({ rows: [ { id: serviceId, project_id: input.projectId, name: input.name, image_url: input.imageUrl, port: input.port, cpu: input.cpu, memory: input.memory, region: input.region, fly_app_id: 'my-api-proj-123', fly_machine_id: null, status: 'deploying', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ], }); mockCreateApp.mockRejectedValue(new Error('Fly API error (500): IP allocation failed')); mockDestroyApp.mockRejectedValue(new Error('Fly API error (502): bad gateway')); // DELETE cleanup still runs despite destroyApp failure. mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); await expect(service.prepareForDeploy(input)).rejects.toThrow(/IP allocation failed/); // destroyApp was attempted (best effort). expect(mockDestroyApp).toHaveBeenCalledWith('my-api-proj-123'); // DB row still cleaned up. const deleteCall = mockQuery.mock.calls[1]; expect(deleteCall[0]).toContain('DELETE FROM compute.services'); }); }); describe('Fly app name length guard', () => { it('throws when projectId is too long to fit in a Fly app name', async () => { const longProjectId = 'a'.repeat(60); await expect( service.prepareForDeploy({ projectId: longProjectId, name: 'api', imageUrl: 'nginx:latest', port: 8080, cpu: 'shared-1x', memory: 512, region: 'iad', }) ).rejects.toThrow(/projectId is too long/); }); }); describe('stopService', () => { const serviceRow = { id: 'svc-stop-1', project_id: 'proj-123', name: 'my-api', image_url: 'nginx:latest', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'my-api-proj-123', fly_machine_id: 'machine-1', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; it('stops machine and updates status to stopped', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStopMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [{ ...serviceRow, status: 'stopped' }] }); // UPDATE const result = await service.stopService('svc-stop-1'); expect(mockStopMachine).toHaveBeenCalledWith('my-api-proj-123', 'machine-1'); expect(result.status).toBe('stopped'); }); it('throws COMPUTE_SERVICE_NOT_FOUND when UPDATE affects zero rows', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStopMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // UPDATE returns nothing await expect(service.stopService('svc-stop-1')).rejects.toThrow('Service not found'); }); it('throws COMPUTE_SERVICE_STOP_FAILED when stopMachine fails', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStopMachine.mockRejectedValue(new Error('Fly error')); await expect(service.stopService('svc-stop-1')).rejects.toThrow(/Failed to stop/); }); }); describe('startService', () => { const serviceRow = { id: 'svc-start-1', project_id: 'proj-123', name: 'my-api', image_url: 'nginx:latest', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'my-api-proj-123', fly_machine_id: 'machine-1', status: 'stopped', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; it('starts machine and updates status to running', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStartMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [{ ...serviceRow, status: 'running' }] }); // UPDATE const result = await service.startService('svc-start-1'); expect(mockStartMachine).toHaveBeenCalledWith('my-api-proj-123', 'machine-1'); expect(result.status).toBe('running'); }); it('throws COMPUTE_SERVICE_NOT_FOUND when UPDATE affects zero rows', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStartMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // UPDATE returns nothing await expect(service.startService('svc-start-1')).rejects.toThrow('Service not found'); }); it('throws COMPUTE_SERVICE_START_FAILED when startMachine fails', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStartMachine.mockRejectedValue(new Error('Fly error')); await expect(service.startService('svc-start-1')).rejects.toThrow(/Failed to start/); }); }); // A machine can vanish on the provider while our row still points at it // (idle reaping, out-of-band delete). Providers throw MachineGoneError on a // definitive 404; the service layer must heal the stale row (fly_machine_id // → NULL, status → stopped) and surface a typed COMPUTE_MACHINE_NOT_FOUND // 404 instead of an opaque 5xx forever. describe('machine-gone healing', () => { const serviceRow = { id: 'svc-ghost-1', project_id: 'proj-123', name: 'my-api', image_url: 'nginx:latest', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'my-api-proj-123', fly_machine_id: 'machine-ghost', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; const gone = () => new MachineGoneError('my-api-proj-123', 'machine-ghost'); function expectHealQuery() { const healCall = mockQuery.mock.calls.find( ([sql]) => typeof sql === 'string' && sql.includes('fly_machine_id = NULL') ); expect(healCall).toBeDefined(); expect(healCall?.[1]).toEqual(['svc-ghost-1', 'machine-ghost']); } it('getServiceEvents heals the row and throws COMPUTE_MACHINE_NOT_FOUND', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockGetEvents.mockRejectedValue(gone()); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // heal UPDATE await expect(service.getServiceEvents('svc-ghost-1')).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.COMPUTE_MACHINE_NOT_FOUND, }); expectHealQuery(); }); it('getServiceLogs heals the row and throws COMPUTE_MACHINE_NOT_FOUND', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockGetLogs.mockRejectedValue(gone()); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // heal UPDATE await expect(service.getServiceLogs('svc-ghost-1')).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.COMPUTE_MACHINE_NOT_FOUND, }); expectHealQuery(); }); it('startService heals the row and throws COMPUTE_MACHINE_NOT_FOUND', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStartMachine.mockRejectedValue(gone()); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // heal UPDATE await expect(service.startService('svc-ghost-1')).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.COMPUTE_MACHINE_NOT_FOUND, }); expectHealQuery(); }); it('stopService on a gone machine succeeds — desired state already true', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockStopMachine.mockRejectedValue(gone()); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // heal UPDATE mockQuery.mockResolvedValueOnce({ rows: [{ ...serviceRow, fly_machine_id: null, status: 'stopped' }], }); // status UPDATE const result = await service.stopService('svc-ghost-1'); expect(result.status).toBe('stopped'); expectHealQuery(); }); it('post-heal requests get COMPUTE_MACHINE_NOT_FOUND + redeploy guidance, not "Service not found"', async () => { const healedRow = { ...serviceRow, fly_machine_id: null, status: 'stopped' }; const calls = [ () => service.getServiceEvents('svc-ghost-1'), () => service.getServiceLogs('svc-ghost-1'), () => service.startService('svc-ghost-1'), () => service.stopService('svc-ghost-1'), ]; for (const call of calls) { mockQuery.mockResolvedValueOnce({ rows: [healedRow] }); // getService await expect(call()).rejects.toMatchObject({ statusCode: 404, code: ERROR_CODES.COMPUTE_MACHINE_NOT_FOUND, }); } }); it('updateService relaunches the stored image when a deploy-field PATCH hits a healed row', async () => { const healedRow = { ...serviceRow, fly_machine_id: null, status: 'stopped' }; mockQuery.mockResolvedValueOnce({ rows: [healedRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockLaunchMachine.mockResolvedValue({ machineId: 'mach-fresh' }); mockQuery.mockResolvedValueOnce({ rows: [{ ...healedRow, fly_machine_id: 'mach-fresh', status: 'running' }], }); // final UPDATE const result = await service.updateService('svc-ghost-1', { envVars: { NEW: 'value' }, }); expect(mockLaunchMachine).toHaveBeenCalledWith( expect.objectContaining({ appId: 'my-api-proj-123', image: 'nginx:latest', // the stored image, relaunched }) ); expect(result.flyMachineId).toBe('mach-fresh'); }); it('updateService does NOT relaunch during the prepare-window (placeholder image)', async () => { // prepareForDeploy rows: status deploying, image_url is a placeholder. const prepareRow = { ...serviceRow, fly_machine_id: null, status: 'deploying', image_url: 'dockerfile', }; mockQuery.mockResolvedValueOnce({ rows: [prepareRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockQuery.mockResolvedValueOnce({ rows: [prepareRow] }); // final UPDATE await service.updateService('svc-ghost-1', { envVars: { NEW: 'value' } }); expect(mockLaunchMachine).not.toHaveBeenCalled(); expect(mockUpdateMachine).not.toHaveBeenCalled(); }); it('transient provider errors do NOT heal the row', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService mockGetEvents.mockRejectedValue(new Error('Fly API error (500): internal')); await expect(service.getServiceEvents('svc-ghost-1')).rejects.toThrow(/500/); const healCall = mockQuery.mock.calls.find( ([sql]) => typeof sql === 'string' && sql.includes('fly_machine_id = NULL') ); expect(healCall).toBeUndefined(); }); }); describe('updateService — Path A first-launch branch', () => { // Service that prepareForDeploy has already created: app exists, but no // machine yet. The CLI now PATCHes with an imageUrl (it just built+pushed) // and we must launch the machine. const baseRow = { id: 'svc-pa-1', project_id: 'proj-123', name: 'my-api', image_url: 'dockerfile', port: 8080, cpu: 'shared-1x', memory: 512, region: 'iad', fly_app_id: 'my-api-proj-123', fly_machine_id: null, status: 'deploying', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; it('happy path: launches machine, persists machineId/status/endpoint', async () => { mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockLaunchMachine.mockResolvedValue({ machineId: 'mach-pa-1' }); mockQuery.mockResolvedValueOnce({ rows: [ { ...baseRow, fly_machine_id: 'mach-pa-1', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', image_url: 'registry.fly.io/my-api-proj-123:deployment-abc', }, ], }); // final UPDATE (with optimistic lock) const result = await service.updateService('svc-pa-1', { imageUrl: 'registry.fly.io/my-api-proj-123:deployment-abc', }); // launchMachine called with the existing app id + the new image expect(mockLaunchMachine).toHaveBeenCalledWith( expect.objectContaining({ appId: 'my-api-proj-123', image: 'registry.fly.io/my-api-proj-123:deployment-abc', port: 8080, cpu: 'shared-1x', memory: 512, region: 'iad', }) ); // updateMachine MUST NOT be called on the first-launch path expect(mockUpdateMachine).not.toHaveBeenCalled(); // The final UPDATE carries the optimistic lock. const updateCall = mockQuery.mock.calls[2]; expect(updateCall[0]).toContain('UPDATE compute.services'); expect(updateCall[0]).toContain('fly_machine_id IS NULL'); expect(updateCall[1]).toContain('mach-pa-1'); expect(updateCall[1]).toContain('running'); expect(result.flyMachineId).toBe('mach-pa-1'); expect(result.status).toBe('running'); expect(result.endpointUrl).toBe('https://my-api-proj-123.fly.dev'); }); it('rewraps structured cloud errors (quota / nextActions surfaces through)', async () => { mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT // CloudComputeProvider wraps the cloud's JSON body verbatim into an // AppError whose .message is the raw body. The catch block must parse // it back out and surface code/message/nextActions. const { AppError } = await import('@/utils/errors.js'); const cloudBody = JSON.stringify({ code: ERROR_CODES.COMPUTE_QUOTA_EXCEEDED, error: 'Project compute quota exceeded', nextActions: ['upgrade plan', 'delete unused services'], }); mockLaunchMachine.mockRejectedValue( new AppError(cloudBody, 403, ERROR_CODES.COMPUTE_QUOTA_EXCEEDED) ); await expect( service.updateService('svc-pa-1', { imageUrl: 'registry.fly.io/x:y' }) ).rejects.toMatchObject({ statusCode: 403, code: ERROR_CODES.COMPUTE_QUOTA_EXCEEDED, message: 'Project compute quota exceeded', }); // Final UPDATE must NOT have run — Fly call failed. expect(mockQuery.mock.calls.some((c) => String(c[0]).startsWith('UPDATE'))).toBe(false); }); // INS-271: updateService forwards protocol through to updateMachine on // the redeploy path. Switching http<->tcp swaps the Fly edge handlers // entirely, so the provider has to know. it('forwards protocol: tcp to updateMachine on redeploy', async () => { const deployedRow = { ...baseRow, fly_machine_id: 'mach-existing', status: 'running', protocol: 'http', }; mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockUpdateMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [{ ...deployedRow, protocol: 'tcp' }], }); await service.updateService('svc-pa-1', { protocol: 'tcp' }); expect(mockUpdateMachine).toHaveBeenCalledWith( expect.objectContaining({ appId: 'my-api-proj-123', machineId: 'mach-existing', protocol: 'tcp', }) ); // SQL UPDATE persists the new protocol value. const updateCall = mockQuery.mock.calls[2]; expect(updateCall[0]).toContain('protocol'); expect(updateCall[1]).toContain('tcp'); }); // Back-compat — if the caller doesn't pass `protocol`, the persisted value // (from `existing.protocol`) flows through. This is how a port-only update // keeps the existing service's protocol setting. it('preserves existing.protocol when update omits the field', async () => { const deployedRow = { ...baseRow, fly_machine_id: 'mach-existing', status: 'running', protocol: 'tcp', }; mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockUpdateMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); // Memory-only update — should still forward the existing tcp protocol. await service.updateService('svc-pa-1', { memory: 1024 }); expect(mockUpdateMachine).toHaveBeenCalledWith(expect.objectContaining({ protocol: 'tcp' })); }); // --always-on: updateService forwards scaleToZero through to updateMachine // on the redeploy path so an existing service can be flipped to always-on // (or back) without delete + redeploy. it('forwards scaleToZero: false to updateMachine on redeploy', async () => { const deployedRow = { ...baseRow, fly_machine_id: 'mach-existing', status: 'running', scale_to_zero: true, }; mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockUpdateMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [{ ...deployedRow, scale_to_zero: false }], }); const result = await service.updateService('svc-pa-1', { scaleToZero: false }); expect(mockUpdateMachine).toHaveBeenCalledWith( expect.objectContaining({ appId: 'my-api-proj-123', machineId: 'mach-existing', scaleToZero: false, }) ); // SQL UPDATE persists the new value. const updateCall = mockQuery.mock.calls[2]; expect(updateCall[0]).toContain('scale_to_zero'); expect(updateCall[1]).toContain(false); expect(result.scaleToZero).toBe(false); }); // Back-compat — a field-only update keeps the persisted always-on setting // flowing to Fly (`data.scaleToZero ?? existing.scaleToZero`), so a plain // redeploy doesn't silently flip an always-on service back to scale-to-zero. it('preserves existing scaleToZero: false when update omits the field', async () => { const deployedRow = { ...baseRow, fly_machine_id: 'mach-existing', status: 'running', scale_to_zero: false, }; mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockUpdateMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); await service.updateService('svc-pa-1', { memory: 1024 }); expect(mockUpdateMachine).toHaveBeenCalledWith( expect.objectContaining({ scaleToZero: false }) ); }); it('regression: existing machine + imageUrl takes the redeploy path (updateMachine, no launch, no optimistic lock)', async () => { const deployedRow = { ...baseRow, fly_machine_id: 'mach-existing', status: 'running' }; mockQuery.mockResolvedValueOnce({ rows: [deployedRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockUpdateMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [{ ...deployedRow, image_url: 'registry.fly.io/my-api:v2' }], }); // final UPDATE (no optimistic lock) await service.updateService('svc-pa-1', { imageUrl: 'registry.fly.io/my-api:v2' }); expect(mockUpdateMachine).toHaveBeenCalledWith( expect.objectContaining({ appId: 'my-api-proj-123', machineId: 'mach-existing', image: 'registry.fly.io/my-api:v2', }) ); expect(mockLaunchMachine).not.toHaveBeenCalled(); const updateCall = mockQuery.mock.calls[2]; expect(updateCall[0]).toContain('UPDATE compute.services'); // No optimistic lock on the redeploy path — only first-launch needs it. expect(updateCall[0]).not.toContain('fly_machine_id IS NULL'); }); it('race condition: concurrent launch loses DB write, destroys orphan, returns winning state', async () => { mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockLaunchMachine.mockResolvedValue({ machineId: 'mach-loser' }); // Final UPDATE returns 0 rows — the other concurrent request already // wrote fly_machine_id, and `AND fly_machine_id IS NULL` filtered us out. mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); mockDestroyMachine.mockResolvedValue(undefined); // Cleanup path: getService returns the winning state. const winningRow = { ...baseRow, fly_machine_id: 'mach-winner', status: 'running' }; mockQuery.mockResolvedValueOnce({ rows: [winningRow] }); // final getService const result = await service.updateService('svc-pa-1', { imageUrl: 'registry.fly.io/my-api-proj-123:deployment-abc', }); // We MUST destroy the machine we just created so it doesn't keep billing. expect(mockDestroyMachine).toHaveBeenCalledWith('my-api-proj-123', 'mach-loser'); // Caller sees the WINNING state, not an error — both requests "succeed" // from the user's POV (idempotent retry). expect(result.flyMachineId).toBe('mach-winner'); expect(result.status).toBe('running'); }); it('race condition: even if cleanup destroyMachine fails, still returns winning state (best-effort)', async () => { mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); // getService mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: null }] }); // hoisted SELECT mockLaunchMachine.mockResolvedValue({ machineId: 'mach-loser' }); mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // optimistic lock filters us out mockDestroyMachine.mockRejectedValue(new Error('Fly transient 503')); const winningRow = { ...baseRow, fly_machine_id: 'mach-winner', status: 'running' }; mockQuery.mockResolvedValueOnce({ rows: [winningRow] }); // final getService const result = await service.updateService('svc-pa-1', { imageUrl: 'registry.fly.io/x:y', }); expect(mockDestroyMachine).toHaveBeenCalled(); expect(result.flyMachineId).toBe('mach-winner'); }); }); describe('updateService — envVarsPatch (partial env edit)', () => { const SERVICE_ID = 'svc-patch-1'; const baseRow = { id: SERVICE_ID, project_id: 'proj-1', name: 'patch-svc', image_url: 'img:1', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'patch-svc-proj-1', fly_machine_id: 'mach-1', status: 'running', endpoint_url: 'https://patch-svc-proj-1.fly.dev', env_vars_encrypted: `encrypted:${JSON.stringify({ KEEP: 'k', ROTATE_ME: 'old' })}`, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; it('merges set keys with existing env, preserves untouched keys', async () => { // 1. getService initial fetch mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); // 2. SELECT env_vars_encrypted for the patch resolution mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: baseRow.env_vars_encrypted }], }); // 3. SELECT env_vars_encrypted for the Fly redeploy merge mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: baseRow.env_vars_encrypted }], }); mockUpdateMachine.mockResolvedValue(undefined); // 4. final UPDATE returning the persisted row mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); await service.updateService(SERVICE_ID, { envVarsPatch: { set: { ROTATE_ME: 'new', NEW_KEY: 'added' } }, }); // Verify Fly received the merged env: KEEP preserved, ROTATE_ME updated, // NEW_KEY added. None of these would be visible to the caller via the // public API (envVars is never returned), so this assertion is the // contract that secret rotation actually works. expect(mockUpdateMachine).toHaveBeenCalledWith( expect.objectContaining({ envVars: { KEEP: 'k', ROTATE_ME: 'new', NEW_KEY: 'added' }, }) ); }); it('removes unset keys while preserving the rest', async () => { mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: baseRow.env_vars_encrypted }], }); mockQuery.mockResolvedValueOnce({ rows: [{ env_vars_encrypted: baseRow.env_vars_encrypted }], }); mockUpdateMachine.mockResolvedValue(undefined); mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); await service.updateService(SERVICE_ID, { envVarsPatch: { unset: ['ROTATE_ME'] }, }); expect(mockUpdateMachine).toHaveBeenCalledWith( expect.objectContaining({ envVars: { KEEP: 'k' }, }) ); }); it('rejects when both envVars (wholesale) and envVarsPatch are sent', async () => { mockQuery.mockResolvedValueOnce({ rows: [baseRow] }); await expect( service.updateService(SERVICE_ID, { envVars: { ALL: 'replace' }, envVarsPatch: { set: { ONE: 'merge' } }, }) ).rejects.toThrow('mutually exclusive'); // No DB write should have happened — the guard rejects before any // mutation. Only the initial getService SELECT ran. expect(mockUpdateMachine).not.toHaveBeenCalled(); }); }); describe('getServiceLogs', () => { const serviceRow = { id: 'svc-logs-1', project_id: 'proj-123', name: 'my-api', image_url: 'nginx:latest', port: 8080, cpu: 'shared-1x', memory: 256, region: 'iad', fly_app_id: 'my-api-proj-123', fly_machine_id: 'machine-1', status: 'running', endpoint_url: 'https://my-api-proj-123.fly.dev', env_vars_encrypted: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; it('delegates to provider.getLogs with the app, machine, and options', async () => { mockQuery.mockResolvedValueOnce({ rows: [serviceRow] }); // getService const payload = { lines: [{ timestamp: 1, message: 'hi' }], nextToken: '42' }; mockGetLogs.mockResolvedValue(payload); const result = await service.getServiceLogs('svc-logs-1', { limit: 200, nextToken: 'cur' }); expect(mockGetLogs).toHaveBeenCalledWith('my-api-proj-123', 'machine-1', { limit: 200, nextToken: 'cur', }); expect(result).toEqual(payload); }); it('throws COMPUTE_SERVICE_NOT_FOUND when the service has no machine yet', async () => { mockQuery.mockResolvedValueOnce({ rows: [{ ...serviceRow, fly_app_id: null, fly_machine_id: null }], }); await expect(service.getServiceLogs('svc-logs-1')).rejects.toThrow('Service not found'); expect(mockGetLogs).not.toHaveBeenCalled(); }); }); }); // NOTE: Route-level integration tests for compute endpoints are deferred — // supertest is not used in this repo. Unit coverage at the service layer is // comprehensive; HTTP-layer wiring is validated via type-checked route // definitions and manual QA. describe('selectComputeProvider factory', () => { beforeEach(() => { vi.resetModules(); // Unmock the provider modules so the factory calls REAL isConfigured() // methods driven by the `config` mock for each test. vi.doUnmock('@/providers/compute/fly.provider.js'); vi.doUnmock('@/providers/compute/cloud.provider.js'); }); it('returns FlyProvider when FLY_API_TOKEN is set', async () => { vi.doMock('@/infra/config/app.config.js', () => { const c = { fly: { apiToken: 'tok', org: 'o', enabled: true, domain: 'd' }, cloud: { projectId: 'local', apiHost: '' }, app: { jwtSecret: 'x' }, }; return { config: c, appConfig: c, }; }); const { selectComputeProvider } = await import('@/services/compute/services.service.js'); const { FlyProvider } = await import('@/providers/compute/fly.provider.js'); expect(selectComputeProvider()).toBe(FlyProvider.getInstance()); }); it('returns CloudComputeProvider when PROJECT_ID is provisioned and no FLY_API_TOKEN', async () => { vi.doMock('@/infra/config/app.config.js', () => { const c = { fly: { apiToken: '', org: '', enabled: false, domain: '' }, cloud: { projectId: 'p', apiHost: 'https://x' }, app: { jwtSecret: 'x' }, }; return { config: c, appConfig: c, }; }); const { selectComputeProvider } = await import('@/services/compute/services.service.js'); const { CloudComputeProvider } = await import('@/providers/compute/cloud.provider.js'); expect(selectComputeProvider()).toBe(CloudComputeProvider.getInstance()); }); it('throws COMPUTE_NOT_CONFIGURED when neither is set', async () => { vi.doMock('@/infra/config/app.config.js', () => { const c = { fly: { apiToken: '', org: '', enabled: false, domain: '' }, cloud: { projectId: 'local', apiHost: '' }, app: { jwtSecret: 'x' }, }; return { config: c, appConfig: c, }; }); const { selectComputeProvider } = await import('@/services/compute/services.service.js'); expect(() => selectComputeProvider()).toThrow(/COMPUTE_NOT_CONFIGURED|not configured/); }); });