409 lines
12 KiB
JavaScript
409 lines
12 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
import request from 'supertest';
|
|
|
|
import { createApp } from '../app.js';
|
|
import { createFakeRedis } from '../test/fakeRedis.js';
|
|
import { trimProjectConsoleLogsByLength } from './logs.js';
|
|
|
|
describe('projects API', () => {
|
|
it('GET /api/projects returns projects from unified list', async () => {
|
|
const now = Date.now();
|
|
const redis = createFakeRedis({
|
|
项目心跳: [
|
|
JSON.stringify({
|
|
projectName: 'Demo',
|
|
apiBaseUrl: 'http://localhost:8080',
|
|
lastActiveAt: now,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app).get('/api/projects');
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.success).toBe(true);
|
|
expect(resp.body.count).toBe(1);
|
|
expect(resp.body.projects[0]).toMatchObject({
|
|
id: 'Demo',
|
|
name: 'Demo',
|
|
apiBaseUrl: 'http://localhost:8080',
|
|
});
|
|
expect([ 'online', 'offline', 'unknown' ]).toContain(
|
|
resp.body.projects[0].status,
|
|
);
|
|
});
|
|
|
|
it('GET /api/projects prunes expired heartbeats from Redis list', async () => {
|
|
const now = Date.now();
|
|
const redis = createFakeRedis({
|
|
项目心跳: [
|
|
JSON.stringify({
|
|
projectName: 'Demo',
|
|
apiBaseUrl: 'http://localhost:8080',
|
|
lastActiveAt: now - 60_000,
|
|
}),
|
|
JSON.stringify({
|
|
projectName: 'Demo',
|
|
apiBaseUrl: 'http://localhost:8080',
|
|
lastActiveAt: now,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app).get('/api/projects');
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.success).toBe(true);
|
|
expect(resp.body.count).toBe(1);
|
|
|
|
const listItems = await redis.lRange('项目心跳', 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
expect(JSON.parse(listItems[0]).lastActiveAt).toBe(now);
|
|
});
|
|
|
|
it('POST /api/projects/migrate migrates old *_项目心跳 keys into 项目心跳 list', async () => {
|
|
const now = Date.now();
|
|
const redis = createFakeRedis({
|
|
'A_项目心跳': JSON.stringify({
|
|
apiBaseUrl: 'http://a',
|
|
lastActiveAt: now,
|
|
}),
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.post('/api/projects/migrate')
|
|
.send({ dryRun: false, deleteOldKeys: true });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.success).toBe(true);
|
|
expect(resp.body.migrated).toBe(1);
|
|
|
|
const listItems = await redis.lRange('项目心跳', 0, -1);
|
|
expect(Array.isArray(listItems)).toBe(true);
|
|
expect(listItems.length).toBe(1);
|
|
const first = JSON.parse(listItems[0]);
|
|
expect(first).toMatchObject({
|
|
projectName: 'A',
|
|
apiBaseUrl: 'http://a',
|
|
});
|
|
|
|
const old = await redis.get('A_项目心跳');
|
|
expect(old).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('logs API', () => {
|
|
it('trimProjectConsoleLogsByLength trims *_项目控制台 lists to 1000 items', async () => {
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const items = Array.from({ length: 1505 }, (_, i) => `l${i + 1}`);
|
|
const redis = createFakeRedis({
|
|
[key]: items,
|
|
other: [ 'x', 'y' ],
|
|
});
|
|
|
|
const result = await trimProjectConsoleLogsByLength(redis, { maxLen: 1000 });
|
|
expect(result).toMatchObject({ keysScanned: 1, keysTrimmed: 1, maxLen: 1000 });
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1000);
|
|
expect(listItems[0]).toBe('l506');
|
|
expect(listItems[listItems.length - 1]).toBe('l1505');
|
|
});
|
|
|
|
it('GET /api/logs uses LOG_TTL_MS=24h without wiping recent logs', async () => {
|
|
const prev = process.env.LOG_TTL_MS;
|
|
process.env.LOG_TTL_MS = '24h';
|
|
|
|
try {
|
|
const now = Date.now();
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'new',
|
|
timestamp: new Date(now - 60 * 60 * 1000).toISOString(),
|
|
level: 'info',
|
|
message: 'new',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0].id).toBe('new');
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
} finally {
|
|
process.env.LOG_TTL_MS = prev;
|
|
}
|
|
});
|
|
|
|
it('GET /api/logs ignores too-small LOG_TTL_MS to avoid mass deletion', async () => {
|
|
const prev = process.env.LOG_TTL_MS;
|
|
process.env.LOG_TTL_MS = '24';
|
|
|
|
try {
|
|
const now = Date.now();
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'new',
|
|
timestamp: new Date(now - 60 * 60 * 1000).toISOString(),
|
|
level: 'info',
|
|
message: 'new',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0].id).toBe('new');
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
} finally {
|
|
process.env.LOG_TTL_MS = prev;
|
|
}
|
|
});
|
|
|
|
it('GET /api/logs prunes logs older than 24h and returns latest', async () => {
|
|
const now = Date.now();
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'old',
|
|
timestamp: new Date(now - 25 * 60 * 60 * 1000).toISOString(),
|
|
level: 'info',
|
|
message: 'old',
|
|
}),
|
|
JSON.stringify({
|
|
id: 'new',
|
|
timestamp: new Date(now - 1000).toISOString(),
|
|
level: 'info',
|
|
message: 'new',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app).get('/api/logs').query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(Array.isArray(resp.body.logs)).toBe(true);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0]).toMatchObject({
|
|
id: 'new',
|
|
message: 'new',
|
|
});
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
expect(JSON.parse(listItems[0]).id).toBe('new');
|
|
});
|
|
|
|
it('GET /api/logs keeps latest 1000 logs when all timestamps are expired', async () => {
|
|
const now = Date.now();
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const items = Array.from({ length: 1505 }, (_, i) =>
|
|
JSON.stringify({
|
|
id: `log-${i + 1}`,
|
|
timestamp: new Date(now - 26 * 60 * 60 * 1000).toISOString(),
|
|
level: 'info',
|
|
message: `m${i + 1}`,
|
|
}),
|
|
);
|
|
const redis = createFakeRedis({
|
|
[key]: items,
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.logs.length).toBe(1000);
|
|
expect(resp.body.logs[0].id).toBe('log-506');
|
|
expect(resp.body.logs[resp.body.logs.length - 1].id).toBe('log-1505');
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1000);
|
|
expect(JSON.parse(listItems[0]).id).toBe('log-506');
|
|
expect(JSON.parse(listItems[listItems.length - 1]).id).toBe('log-1505');
|
|
});
|
|
|
|
it('GET /api/logs keeps unix-second timestamps and prunes correctly', async () => {
|
|
const now = Date.now();
|
|
const nowSec = Math.floor(now / 1000);
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'old',
|
|
timestamp: nowSec - 25 * 60 * 60,
|
|
level: 'info',
|
|
message: 'old',
|
|
}),
|
|
JSON.stringify({
|
|
id: 'new',
|
|
timestamp: nowSec - 1,
|
|
level: 'info',
|
|
message: 'new',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(Array.isArray(resp.body.logs)).toBe(true);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0]).toMatchObject({
|
|
id: 'new',
|
|
message: 'new',
|
|
});
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
expect(JSON.parse(listItems[0]).id).toBe('new');
|
|
});
|
|
|
|
it('GET /api/logs keeps numeric-string unix-second timestamps and prunes correctly', async () => {
|
|
const now = Date.now();
|
|
const nowSec = Math.floor(now / 1000);
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'old',
|
|
timestamp: String(nowSec - 25 * 60 * 60),
|
|
level: 'info',
|
|
message: 'old',
|
|
}),
|
|
JSON.stringify({
|
|
id: 'new',
|
|
timestamp: String(nowSec - 1),
|
|
level: 'info',
|
|
message: 'new',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(Array.isArray(resp.body.logs)).toBe(true);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0]).toMatchObject({
|
|
id: 'new',
|
|
message: 'new',
|
|
});
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
expect(JSON.parse(listItems[0]).id).toBe('new');
|
|
});
|
|
|
|
it('GET /api/logs does not delete plain-string log lines', async () => {
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [ 'plain log line' ],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0].message).toBe('plain log line');
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
expect(listItems[0]).toBe('plain log line');
|
|
});
|
|
|
|
it('GET /api/logs does not delete logs with non-epoch numeric timestamps', async () => {
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'x',
|
|
timestamp: 12345,
|
|
level: 'info',
|
|
message: 'x',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app)
|
|
.get('/api/logs')
|
|
.query({ projectName, limit: 1000 });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.logs.length).toBe(1);
|
|
expect(resp.body.logs[0].message).toBe('x');
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(1);
|
|
expect(JSON.parse(listItems[0]).id).toBe('x');
|
|
});
|
|
|
|
it('POST /api/logs/clear deletes all logs for project', async () => {
|
|
const projectName = 'Demo';
|
|
const key = `${projectName}_项目控制台`;
|
|
const redis = createFakeRedis({
|
|
[key]: [
|
|
JSON.stringify({
|
|
id: 'a',
|
|
timestamp: new Date().toISOString(),
|
|
level: 'info',
|
|
message: 'a',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const app = createApp({ redis });
|
|
const resp = await request(app).post('/api/logs/clear').send({ projectName });
|
|
|
|
expect(resp.status).toBe(200);
|
|
expect(resp.body.success).toBe(true);
|
|
|
|
const listItems = await redis.lRange(key, 0, -1);
|
|
expect(listItems.length).toBe(0);
|
|
});
|
|
});
|