520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { DenoSubhostingProvider } from '../../src/providers/functions/deno-subhosting.provider';
|
|
import { LogService } from '../../src/services/logs/log.service';
|
|
import { FunctionService } from '../../src/services/functions/function.service';
|
|
import { AppError } from '../../src/utils/errors';
|
|
import { ERROR_CODES } from '@insforge/shared-schemas';
|
|
|
|
// Mock node-fetch — use vi.hoisted so the variable is available in the hoisted vi.mock factory
|
|
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
|
vi.mock('node-fetch', () => ({
|
|
default: mockFetch,
|
|
Request: vi.fn(),
|
|
Response: vi.fn(),
|
|
Headers: vi.fn(),
|
|
}));
|
|
|
|
// Mock logger
|
|
vi.mock('../../src/utils/logger', () => ({
|
|
default: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock config. `cloud.appKey` is the Deno app slug the v2 logs endpoint is
|
|
// scoped to (GET /v2/apps/{slug}/logs).
|
|
vi.mock('../../src/infra/config/app.config', () => ({
|
|
appConfig: {
|
|
denoSubhosting: {
|
|
token: 'test-deno-token',
|
|
organizationId: 'test-org-id',
|
|
domain: 'function2.insforge.app',
|
|
},
|
|
cloud: {
|
|
appKey: 'test-app-key',
|
|
},
|
|
},
|
|
}));
|
|
|
|
// Mock pool — use vi.hoisted so it's available in the hoisted vi.mock factory
|
|
const { mockPool } = vi.hoisted(() => ({
|
|
mockPool: { query: vi.fn(), connect: vi.fn() },
|
|
}));
|
|
|
|
// Mock DatabaseManager to avoid real DB connections
|
|
vi.mock('../../src/infra/database/database.manager', () => ({
|
|
DatabaseManager: {
|
|
getInstance: () => ({
|
|
getPool: () => mockPool,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Mock SecretService
|
|
vi.mock('../../src/services/secrets/secret.service', () => ({
|
|
SecretService: {
|
|
getInstance: () => ({
|
|
listSecrets: vi.fn().mockResolvedValue([]),
|
|
getSecretByKey: vi.fn().mockResolvedValue(null),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Mock environment util
|
|
vi.mock('../../src/utils/environment', () => ({
|
|
isCloudEnvironment: () => false,
|
|
}));
|
|
|
|
// Mock the log providers to avoid initialization issues
|
|
// Use vi.hoisted so mockLocalProvider is available in the hoisted mock factory
|
|
const { mockLocalProvider } = vi.hoisted(() => ({
|
|
mockLocalProvider: {
|
|
initialize: vi.fn(),
|
|
getLogSources: vi.fn().mockResolvedValue([]),
|
|
getLogsBySource: vi.fn().mockResolvedValue({ logs: [], total: 0, tableName: 'local' }),
|
|
getLogSourceStats: vi.fn().mockResolvedValue([]),
|
|
searchLogs: vi.fn().mockResolvedValue({ logs: [], total: 0 }),
|
|
close: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/providers/logs/cloudwatch.provider', () => ({
|
|
CloudWatchProvider: vi.fn().mockImplementation(() => mockLocalProvider),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/logs/local.provider', () => ({
|
|
LocalFileProvider: vi.fn().mockImplementation(() => mockLocalProvider),
|
|
}));
|
|
|
|
// Helper to create a mock fetch Response. The v2 logs endpoint returns a JSON
|
|
// object { logs, next_cursor } (consumed via response.json()).
|
|
function createMockResponse(body: unknown, status = 200) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: status === 200 ? 'OK' : 'Error',
|
|
json: () => Promise.resolve(body),
|
|
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
|
headers: {
|
|
get: () => null,
|
|
},
|
|
};
|
|
}
|
|
|
|
// v2 RuntimeLogsResponse builder.
|
|
function logsResponse(
|
|
entries: Array<{ timestamp: string; level: string; message: string; region?: string }>,
|
|
nextCursor: string | null = null
|
|
) {
|
|
return createMockResponse({ logs: entries, next_cursor: nextCursor });
|
|
}
|
|
|
|
// ============================================
|
|
// DenoSubhostingProvider.getDeploymentAppLogs (v2)
|
|
// ============================================
|
|
|
|
describe('DenoSubhostingProvider.getDeploymentAppLogs', () => {
|
|
let provider: DenoSubhostingProvider;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
provider = DenoSubhostingProvider.getInstance();
|
|
});
|
|
|
|
it('fetches app logs and normalizes timestamp -> time', async () => {
|
|
const entries = [
|
|
{
|
|
timestamp: '2025-01-15T10:00:00Z',
|
|
level: 'info',
|
|
message: 'Hello from function',
|
|
region: 'us-east1',
|
|
},
|
|
{
|
|
timestamp: '2025-01-15T10:00:01Z',
|
|
level: 'error',
|
|
message: 'Something failed',
|
|
region: 'us-east1',
|
|
},
|
|
];
|
|
|
|
mockFetch.mockResolvedValue(logsResponse(entries));
|
|
|
|
const result = await provider.getDeploymentAppLogs('rev-123');
|
|
|
|
expect(result.logs).toHaveLength(2);
|
|
expect(result.logs[0].message).toBe('Hello from function');
|
|
expect(result.logs[0].time).toBe('2025-01-15T10:00:00Z');
|
|
expect(result.logs[1].level).toBe('error');
|
|
expect(result.cursor).toBeNull();
|
|
expect(result.hasMore).toBe(false);
|
|
|
|
// v2 endpoint is app-scoped and filtered by revision_id; auth header present.
|
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
expect(calledUrl).toContain('https://api.deno.com/v2/apps/test-app-key/logs?');
|
|
expect(calledUrl).toContain('revision_id=rev-123');
|
|
// start is required by v2 — provider must always supply it.
|
|
expect(calledUrl).toContain('start=');
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer test-deno-token' }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('passes query parameters correctly', async () => {
|
|
mockFetch.mockResolvedValue(logsResponse([]));
|
|
|
|
await provider.getDeploymentAppLogs('rev-123', {
|
|
query: 'error',
|
|
level: 'error,warning',
|
|
start: '2025-01-15T00:00:00Z',
|
|
end: '2025-01-15T23:59:59Z',
|
|
limit: 50,
|
|
});
|
|
|
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
expect(calledUrl).toContain('query=error');
|
|
expect(calledUrl).toContain('level=error%2Cwarning');
|
|
expect(calledUrl).toContain('start=2025-01-15T00%3A00%3A00Z');
|
|
expect(calledUrl).toContain('end=2025-01-15T23%3A59%3A59Z');
|
|
expect(calledUrl).toContain('limit=50');
|
|
});
|
|
|
|
it('extracts cursor from next_cursor', async () => {
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse(
|
|
[
|
|
{
|
|
timestamp: '2025-01-15T10:00:00Z',
|
|
level: 'info',
|
|
message: 'Log entry',
|
|
region: 'us-east1',
|
|
},
|
|
],
|
|
'abc123'
|
|
)
|
|
);
|
|
|
|
const result = await provider.getDeploymentAppLogs('rev-123');
|
|
|
|
expect(result.cursor).toBe('abc123');
|
|
expect(result.hasMore).toBe(true);
|
|
});
|
|
|
|
it('returns null cursor when next_cursor is null', async () => {
|
|
mockFetch.mockResolvedValue(logsResponse([]));
|
|
|
|
const result = await provider.getDeploymentAppLogs('rev-123');
|
|
|
|
expect(result.cursor).toBeNull();
|
|
expect(result.hasMore).toBe(false);
|
|
});
|
|
|
|
it('defaults region to empty string when omitted', async () => {
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse([{ timestamp: '2025-01-15T10:00:00Z', level: 'info', message: 'no region' }])
|
|
);
|
|
|
|
const result = await provider.getDeploymentAppLogs('rev-123');
|
|
|
|
expect(result.logs[0].region).toBe('');
|
|
});
|
|
|
|
it('throws AppError on 404', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse('Not found', 404));
|
|
|
|
await expect(provider.getDeploymentAppLogs('nonexistent')).rejects.toThrow(AppError);
|
|
await expect(provider.getDeploymentAppLogs('nonexistent')).rejects.toThrow(
|
|
'Deployment not found: nonexistent'
|
|
);
|
|
});
|
|
|
|
it('throws AppError on API error', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse('Internal Server Error', 500));
|
|
|
|
await expect(provider.getDeploymentAppLogs('rev-123')).rejects.toMatchObject({
|
|
statusCode: 500,
|
|
code: ERROR_CODES.UPSTREAM_FAILURE,
|
|
message: 'Internal Server Error',
|
|
});
|
|
});
|
|
|
|
it('throws AppError on network failure', async () => {
|
|
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
|
|
await expect(provider.getDeploymentAppLogs('rev-123')).rejects.toMatchObject({
|
|
statusCode: 502,
|
|
code: ERROR_CODES.UPSTREAM_FAILURE,
|
|
message: 'Network error',
|
|
});
|
|
});
|
|
|
|
it('returns empty array when no logs', async () => {
|
|
mockFetch.mockResolvedValue(logsResponse([]));
|
|
|
|
const result = await provider.getDeploymentAppLogs('rev-123');
|
|
|
|
expect(result.logs).toHaveLength(0);
|
|
expect(result.hasMore).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// LogService integration with Deno app logs
|
|
// ============================================
|
|
|
|
describe('LogService.getLogsBySource with Deno Subhosting', () => {
|
|
let logService: LogService;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetAllMocks();
|
|
|
|
logService = LogService.getInstance();
|
|
|
|
// Directly set the internal provider to avoid calling initialize() which
|
|
// creates real provider instances. We inject a mock provider via type cast.
|
|
const mockProvider = {
|
|
initialize: vi.fn(),
|
|
getLogSources: vi.fn().mockResolvedValue([]),
|
|
getLogsBySource: vi.fn().mockResolvedValue({ logs: [], total: 0, tableName: 'local' }),
|
|
getLogSourceStats: vi.fn().mockResolvedValue([]),
|
|
searchLogs: vi.fn().mockResolvedValue({ logs: [], total: 0 }),
|
|
close: vi.fn(),
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(logService as any).provider = mockProvider;
|
|
});
|
|
|
|
it('routes function.logs to Deno API when Subhosting is configured', async () => {
|
|
// Mock DB query for latest deployment (revision) ID
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [{ id: 'rev-latest' }],
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse([
|
|
{
|
|
timestamp: '2025-01-15T10:00:00Z',
|
|
level: 'info',
|
|
message: 'Function executed',
|
|
region: 'us-east1',
|
|
},
|
|
{
|
|
timestamp: '2025-01-15T10:00:05Z',
|
|
level: 'warning',
|
|
message: 'Slow query',
|
|
region: 'us-east1',
|
|
},
|
|
])
|
|
);
|
|
|
|
const result = await logService.getLogsBySource('function.logs', 100);
|
|
|
|
expect(result.tableName).toBe('deno-subhosting');
|
|
expect(result.logs).toHaveLength(2);
|
|
// Plain-text lines surface as event_message; severity comes from
|
|
// body.metadata.level (the shape the dashboard reads).
|
|
expect(result.logs[0].eventMessage).toBe('Function executed');
|
|
expect(result.logs[0].timestamp).toBe('2025-01-15T10:00:00Z');
|
|
expect(result.logs[0].body).toEqual({
|
|
event_message: 'Function executed',
|
|
metadata: { level: 'info', region: 'us-east1' },
|
|
});
|
|
expect(result.logs[1].eventMessage).toBe('Slow query');
|
|
expect(result.logs[1].body.metadata).toEqual({ level: 'warning', region: 'us-east1' });
|
|
});
|
|
|
|
it('parses structured router request logs into an access line', async () => {
|
|
mockPool.query.mockResolvedValue({ rows: [{ id: 'rev-latest' }] });
|
|
|
|
// The auto-generated router emits this via console.log(JSON.stringify(...));
|
|
// Deno captures it verbatim with a trailing newline.
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse([
|
|
{
|
|
timestamp: '2025-01-15T10:00:00Z',
|
|
level: 'info',
|
|
message:
|
|
'{"timestamp":"2025-01-15T10:00:00Z","slug":"server-timestamp","method":"GET","status":200,"duration":"1ms"}\n',
|
|
region: '',
|
|
},
|
|
])
|
|
);
|
|
|
|
const result = await logService.getLogsBySource('function.logs', 100);
|
|
|
|
expect(result.logs[0].eventMessage).toBe('GET server-timestamp 200 1ms');
|
|
expect(result.logs[0].body.event_message).toBe('GET server-timestamp 200 1ms');
|
|
expect(result.logs[0].body.metadata).toEqual({ level: 'info' });
|
|
// Parsed fields stay in the body for the detail panel.
|
|
expect(result.logs[0].body.slug).toBe('server-timestamp');
|
|
expect(result.logs[0].body.status).toBe(200);
|
|
});
|
|
|
|
it('lifts a structured log level into metadata and uses its message', async () => {
|
|
mockPool.query.mockResolvedValue({ rows: [{ id: 'rev-latest' }] });
|
|
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse([
|
|
{
|
|
timestamp: '2025-01-15T10:00:00Z',
|
|
level: 'info', // Deno's line level…
|
|
message: '{"level":"error","message":"cache miss"}', // …overridden by the structured level
|
|
region: 'us-east1',
|
|
},
|
|
])
|
|
);
|
|
|
|
const result = await logService.getLogsBySource('function.logs', 100);
|
|
|
|
expect(result.logs[0].eventMessage).toBe('cache miss');
|
|
expect(result.logs[0].body.metadata).toEqual({ level: 'error', region: 'us-east1' });
|
|
});
|
|
|
|
it('also works with deno-relay-logs source name', async () => {
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [{ id: 'rev-latest' }],
|
|
});
|
|
mockFetch.mockResolvedValue(logsResponse([]));
|
|
|
|
const result = await logService.getLogsBySource('deno-relay-logs', 50);
|
|
|
|
expect(result.tableName).toBe('deno-subhosting');
|
|
});
|
|
|
|
it('returns empty logs when no successful deployment exists', async () => {
|
|
mockPool.query.mockResolvedValue({ rows: [] });
|
|
|
|
const result = await logService.getLogsBySource('function.logs', 100);
|
|
|
|
expect(result.logs).toHaveLength(0);
|
|
expect(result.total).toBe(0);
|
|
expect(result.tableName).toBe('deno-subhosting');
|
|
// Should not have called Deno API
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes beforeTimestamp as the end parameter to Deno API', async () => {
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [{ id: 'rev-latest' }],
|
|
});
|
|
mockFetch.mockResolvedValue(logsResponse([]));
|
|
|
|
await logService.getLogsBySource('function.logs', 50, '2025-01-15T09:00:00Z');
|
|
|
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
expect(calledUrl).toContain('end=2025-01-15T09%3A00%3A00Z');
|
|
expect(calledUrl).toContain('limit=50');
|
|
// provider derives a default start window (24h before end).
|
|
expect(calledUrl).toContain('start=2025-01-14T09%3A00%3A00');
|
|
});
|
|
|
|
it('does not send a level filter so all severities are returned', async () => {
|
|
// Deno's `level` param is an exact-match filter, not a min-severity threshold.
|
|
// Omitting it surfaces the full runtime log stream (error, warning, info, debug).
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [{ id: 'rev-latest' }],
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse([
|
|
{
|
|
timestamp: '2025-01-15T10:00:00Z',
|
|
level: 'debug',
|
|
message: 'isolate start time',
|
|
region: 'us-east1',
|
|
},
|
|
{
|
|
timestamp: '2025-01-15T10:00:01Z',
|
|
level: 'info',
|
|
message: 'request handled',
|
|
region: 'us-east1',
|
|
},
|
|
{ timestamp: '2025-01-15T10:00:02Z', level: 'error', message: 'boom', region: 'us-east1' },
|
|
])
|
|
);
|
|
|
|
const result = await logService.getLogsBySource('function.logs', 100);
|
|
|
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
expect(calledUrl).not.toContain('level=');
|
|
// All severities flow through, not just debug
|
|
expect(result.logs.map((l) => (l.body.metadata as { level: string }).level)).toEqual([
|
|
'debug',
|
|
'info',
|
|
'error',
|
|
]);
|
|
});
|
|
|
|
it('generates unique log IDs from deployment ID and timestamp', async () => {
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [{ id: 'rev-abc' }],
|
|
});
|
|
|
|
mockFetch.mockResolvedValue(
|
|
logsResponse([
|
|
{ timestamp: '2025-01-15T10:00:00Z', level: 'info', message: 'Log 1', region: 'us-east1' },
|
|
{ timestamp: '2025-01-15T10:00:00Z', level: 'info', message: 'Log 2', region: 'us-east1' },
|
|
])
|
|
);
|
|
|
|
const result = await logService.getLogsBySource('function.logs', 100);
|
|
|
|
// Same timestamp but different index ensures unique IDs
|
|
expect(result.logs[0].id).toBe('deno-rev-abc-2025-01-15T10:00:00Z-0');
|
|
expect(result.logs[1].id).toBe('deno-rev-abc-2025-01-15T10:00:00Z-1');
|
|
});
|
|
|
|
it('falls back to provider for non-function-logs sources', async () => {
|
|
const result = await logService.getLogsBySource('insforge.logs', 100);
|
|
|
|
// Should use the mocked local provider, not Deno
|
|
expect(result.tableName).toBe('local');
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// FunctionService.getLatestSuccessfulDeploymentId
|
|
// ============================================
|
|
|
|
describe('FunctionService.getLatestSuccessfulDeploymentId', () => {
|
|
let functionService: FunctionService;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
functionService = FunctionService.getInstance();
|
|
});
|
|
|
|
it('returns deployment ID when successful deployment exists', async () => {
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [{ id: 'deploy-success-1' }],
|
|
});
|
|
|
|
const id = await functionService.getLatestSuccessfulDeploymentId();
|
|
|
|
expect(id).toBe('deploy-success-1');
|
|
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining("status = 'success'"));
|
|
});
|
|
|
|
it('returns null when no successful deployment exists', async () => {
|
|
mockPool.query.mockResolvedValue({ rows: [] });
|
|
|
|
const id = await functionService.getLatestSuccessfulDeploymentId();
|
|
|
|
expect(id).toBeNull();
|
|
});
|
|
|
|
it('returns null on database error', async () => {
|
|
mockPool.query.mockRejectedValue(new Error('DB connection lost'));
|
|
|
|
const id = await functionService.getLatestSuccessfulDeploymentId();
|
|
|
|
expect(id).toBeNull();
|
|
});
|
|
});
|