Files
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

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();
});
});