v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
Some checks failed
test / test (push) Failing after 24s
Реализация целевого дизайна (docs/architecture/target-design.md): - src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images) - Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue) - Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов - Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history - Даунскейл sharp до 1024px перед Codex + масштабирование области референса - SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/) - Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data - Документация: README, docs/architecture/
This commit is contained in:
324
test/api.test.js
Normal file
324
test/api.test.js
Normal file
@@ -0,0 +1,324 @@
|
||||
"use strict";
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const request = require("supertest");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const sharp = require("sharp");
|
||||
const { EventEmitter } = require("events");
|
||||
|
||||
const { createApp } = require("../src/http/app");
|
||||
const { loadConfig } = require("../src/config");
|
||||
const { createLogger } = require("../src/logger");
|
||||
const { hashPasswordSync } = require("../src/core/auth/password");
|
||||
const { SessionStore } = require("../src/core/auth/session-store");
|
||||
const { HistoryStore } = require("../src/core/history/history-store");
|
||||
const { HistoryService } = require("../src/core/history/history-service");
|
||||
const { JobStore } = require("../src/core/jobs/job-store");
|
||||
const { Queue } = require("../src/core/jobs/queue");
|
||||
const { JobService } = require("../src/core/jobs/job-service");
|
||||
const { EngineRegistry } = require("../src/core/engines/engine");
|
||||
|
||||
describe("API integration tests", () => {
|
||||
let tempDir;
|
||||
let app;
|
||||
let historyService;
|
||||
let jobService;
|
||||
let queue;
|
||||
let sessionStore;
|
||||
let password = "testpassword123";
|
||||
let passwordHash;
|
||||
let samplePngBuffer;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kadr-api-test-"));
|
||||
const historyDir = path.join(tempDir, "history");
|
||||
const dataDir = tempDir;
|
||||
|
||||
fs.mkdirSync(historyDir, { recursive: true });
|
||||
|
||||
samplePngBuffer = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
}).png().toBuffer();
|
||||
|
||||
const config = loadConfig({
|
||||
DATA_DIR: dataDir,
|
||||
HISTORY_DIR: historyDir,
|
||||
APP_USER: "admin",
|
||||
APP_PASSWORD: password,
|
||||
LOGIN_RATE_LIMIT_MAX: "3",
|
||||
LOGIN_RATE_LIMIT_WINDOW_MS: "10000",
|
||||
});
|
||||
|
||||
const logger = createLogger({ level: "error", stream: { write: () => {} } });
|
||||
passwordHash = hashPasswordSync(password);
|
||||
|
||||
const historyStore = new HistoryStore({ dir: historyDir, limit: 10, logger });
|
||||
historyService = new HistoryService({ store: historyStore, logger });
|
||||
|
||||
sessionStore = new SessionStore({
|
||||
file: path.join(dataDir, "sessions.json"),
|
||||
ttlMs: 3600000,
|
||||
persist: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const jobStore = new JobStore({
|
||||
file: path.join(dataDir, "jobs.jsonl"),
|
||||
logger,
|
||||
});
|
||||
|
||||
const events = new EventEmitter();
|
||||
const engineRegistry = new EngineRegistry();
|
||||
|
||||
// Stub engines
|
||||
engineRegistry.register("edit", () => ({
|
||||
async run(job) {
|
||||
const v = await historyService.createVersion({
|
||||
kind: "edit",
|
||||
label: "Stub edit",
|
||||
prompt: job.payload?.prompt,
|
||||
parentId: job.payload?.sourceId,
|
||||
buffer: samplePngBuffer,
|
||||
ext: "png",
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
return { ok: true, version: v };
|
||||
},
|
||||
}));
|
||||
|
||||
engineRegistry.register("upscale", () => ({
|
||||
async run(job) {
|
||||
const v = await historyService.createVersion({
|
||||
kind: "upscale",
|
||||
label: "Stub upscale",
|
||||
parentId: job.payload?.sourceId,
|
||||
buffer: samplePngBuffer,
|
||||
ext: "png",
|
||||
width: 400,
|
||||
height: 400,
|
||||
});
|
||||
return { ok: true, version: v };
|
||||
},
|
||||
}));
|
||||
|
||||
engineRegistry.register("suggest", () => ({
|
||||
async run() {
|
||||
return { ok: true, extra: { suggestions: ["Вариант 1", "Вариант 2"] } };
|
||||
},
|
||||
}));
|
||||
|
||||
queue = new Queue({
|
||||
concurrencyByGroup: { codex: 1, "comfy-upscale": 2 },
|
||||
maxQueuedJobs: 5,
|
||||
jobStore,
|
||||
engineRegistry,
|
||||
engineContext: {},
|
||||
events,
|
||||
logger,
|
||||
});
|
||||
|
||||
jobService = new JobService({
|
||||
store: jobStore,
|
||||
queue,
|
||||
dataDir,
|
||||
logger,
|
||||
});
|
||||
|
||||
queue.start();
|
||||
|
||||
const comfyClient = {
|
||||
viewStream: async () => {
|
||||
throw new Error("ComfyClient not mocked for stream");
|
||||
},
|
||||
};
|
||||
|
||||
const comfyRuntime = {
|
||||
getRuntimeInfo: async () => ({
|
||||
nodes: {
|
||||
VAELoader: true,
|
||||
UpscaleModelLoader: true,
|
||||
ImageUpscaleWithModel: true,
|
||||
ImageScale: true,
|
||||
SaveImage: true,
|
||||
},
|
||||
upscaleModels: ["4x_UltraSharp.pth"],
|
||||
selectedUpscaleModel: "4x_UltraSharp.pth",
|
||||
checkpoints: ["sdxl_turbo.safetensors"],
|
||||
checkpointAvailable: true,
|
||||
}),
|
||||
};
|
||||
|
||||
app = createApp({
|
||||
config,
|
||||
logger,
|
||||
sessionStore,
|
||||
passwordHash,
|
||||
historyService,
|
||||
jobService,
|
||||
comfyClient,
|
||||
comfyRuntime,
|
||||
events,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (queue) await queue.stop();
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/health is public and returns 200 with version and status", async () => {
|
||||
const res = await request(app).get("/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.version, "2.0.0");
|
||||
assert.equal(res.body.comfyAvailable, true);
|
||||
});
|
||||
|
||||
test("Protected endpoints require authentication", async () => {
|
||||
const res = await request(app).get("/api/history");
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.ok, false);
|
||||
assert.ok(res.body.requestId);
|
||||
});
|
||||
|
||||
test("Login, session cookie, and /api/me flow", async () => {
|
||||
// Bad login
|
||||
const badRes = await request(app)
|
||||
.post("/api/login")
|
||||
.send({ username: "admin", password: "wrongpassword" });
|
||||
assert.equal(badRes.status, 401);
|
||||
assert.equal(badRes.body.ok, false);
|
||||
|
||||
// Good login
|
||||
const loginRes = await request(app)
|
||||
.post("/api/login")
|
||||
.send({ username: "admin", password: "testpassword123" });
|
||||
assert.equal(loginRes.status, 200);
|
||||
assert.equal(loginRes.body.ok, true);
|
||||
assert.equal(loginRes.body.user, "admin");
|
||||
|
||||
const cookie = loginRes.headers["set-cookie"];
|
||||
assert.ok(cookie);
|
||||
|
||||
// /api/me with session cookie
|
||||
const meRes = await request(app)
|
||||
.get("/api/me")
|
||||
.set("Cookie", cookie);
|
||||
assert.equal(meRes.status, 200);
|
||||
assert.equal(meRes.body.ok, true);
|
||||
assert.equal(meRes.body.user, "admin");
|
||||
|
||||
// Rate limit test: max is 3 failures, so after 3 failures the 4th attempt is rejected with 429
|
||||
await request(app).post("/api/login").send({ username: "admin", password: "bad" });
|
||||
await request(app).post("/api/login").send({ username: "admin", password: "bad" });
|
||||
await request(app).post("/api/login").send({ username: "admin", password: "bad" });
|
||||
const rateLimitedRes = await request(app).post("/api/login").send({ username: "admin", password: "bad" });
|
||||
assert.equal(rateLimitedRes.status, 429);
|
||||
});
|
||||
|
||||
test("History import, retrieval, image streaming and deletion", async () => {
|
||||
// Login to get cookie
|
||||
const loginRes = await request(app)
|
||||
.post("/api/login")
|
||||
.send({ username: "admin", password: "testpassword123" });
|
||||
const cookie = loginRes.headers["set-cookie"];
|
||||
|
||||
// 1. Import photo
|
||||
const importRes = await request(app)
|
||||
.post("/api/history/import")
|
||||
.set("Cookie", cookie)
|
||||
.attach("photo", samplePngBuffer, "photo.png");
|
||||
|
||||
assert.equal(importRes.status, 200);
|
||||
assert.equal(importRes.body.ok, true);
|
||||
const version = importRes.body.version;
|
||||
assert.ok(version.id);
|
||||
assert.equal(version.kind, "original");
|
||||
assert.equal(version.ext, "png");
|
||||
assert.equal(version.url, `/api/history/${version.id}/image.png`);
|
||||
|
||||
// 2. Reject non-image import
|
||||
const badImport = await request(app)
|
||||
.post("/api/history/import")
|
||||
.set("Cookie", cookie)
|
||||
.attach("photo", Buffer.from("not an image at all"), "fake.png");
|
||||
assert.equal(badImport.status, 400);
|
||||
|
||||
// 3. GET /api/history
|
||||
const historyRes = await request(app)
|
||||
.get("/api/history")
|
||||
.set("Cookie", cookie);
|
||||
assert.equal(historyRes.status, 200);
|
||||
assert.equal(historyRes.body.versions.length, 1);
|
||||
|
||||
// 4. GET /api/history/:id/image.png
|
||||
const imageRes = await request(app)
|
||||
.get(`/api/history/${version.id}/image.png`)
|
||||
.set("Cookie", cookie);
|
||||
assert.equal(imageRes.status, 200);
|
||||
assert.equal(imageRes.headers["content-type"], "image/png");
|
||||
|
||||
// 5. GET /api/history/:id/image.jpg alias redirects 302 to actual .png
|
||||
const redirectRes = await request(app)
|
||||
.get(`/api/history/${version.id}/image.jpg`)
|
||||
.set("Cookie", cookie);
|
||||
assert.equal(redirectRes.status, 302);
|
||||
assert.equal(redirectRes.headers["location"], `/api/history/${version.id}/image.png`);
|
||||
|
||||
// 6. Delete version
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/history/${version.id}`)
|
||||
.set("Cookie", cookie);
|
||||
assert.equal(deleteRes.status, 200);
|
||||
assert.equal(deleteRes.body.ok, true);
|
||||
assert.equal(deleteRes.body.versions.length, 0);
|
||||
});
|
||||
|
||||
test("Edit and upscale job submission and status", async () => {
|
||||
const loginRes = await request(app)
|
||||
.post("/api/login")
|
||||
.send({ username: "admin", password: "testpassword123" });
|
||||
const cookie = loginRes.headers["set-cookie"];
|
||||
|
||||
// Import initial version
|
||||
const importRes = await request(app)
|
||||
.post("/api/history/import")
|
||||
.set("Cookie", cookie)
|
||||
.attach("photo", samplePngBuffer, "photo.png");
|
||||
const version = importRes.body.version;
|
||||
|
||||
// POST /api/edit
|
||||
const editRes = await request(app)
|
||||
.post("/api/edit")
|
||||
.set("Cookie", cookie)
|
||||
.field("sourceId", version.id)
|
||||
.field("prompt", "Сделай красивый закат");
|
||||
|
||||
assert.equal(editRes.status, 200);
|
||||
assert.equal(editRes.body.ok, true);
|
||||
assert.ok(editRes.body.jobId);
|
||||
assert.equal(editRes.body.status, "queued");
|
||||
|
||||
const jobId = editRes.body.jobId;
|
||||
|
||||
// Poll until done (stub engine completes immediately)
|
||||
while (jobService.getJob(jobId)?.status !== "done") {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
|
||||
const statusRes = await request(app)
|
||||
.get(`/api/jobs/${jobId}/status`)
|
||||
.set("Cookie", cookie);
|
||||
assert.equal(statusRes.status, 200);
|
||||
assert.equal(statusRes.body.status, "done");
|
||||
assert.ok(statusRes.body.result?.version);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user