1748 lines
63 KiB
TypeScript
1748 lines
63 KiB
TypeScript
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/);
|
|
});
|
|
});
|