Files
Web_BLS_ProjectConsole/src/backend/routes/projects.integration.test.js
XuJiacheng 7ac3949dfa feat: 重构项目心跳为Redis LIST并更新相关文档
重构项目心跳数据结构为Redis LIST,更新相关文档和OpenSpec规范。主要变更包括:
- 将项目心跳从STRING改为LIST类型
- 更新后端服务以支持LIST操作
- 同步更新文档和OpenSpec规范
- 统一后端端口为3001
- 添加部署指南和Windows部署文档

修复前端API请求路径,移除硬编码的localhost地址。添加PM2和Nginx配置文件模板,完善部署流程文档。更新Redis集成协议文档,明确LIST数据结构和外部项目对接规范。
2026-01-17 18:36:52 +08:00

358 lines
10 KiB
JavaScript

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