import { describe, expect, it } from 'vitest'; import request from 'supertest'; import { createApp } from '../app.js'; import { createFakeRedis } from '../test/fakeRedis.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('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 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); }); });