Files
photo-editor/test/api.test.js
Coder 401969cfdb
Some checks failed
test / test (push) Failing after 24s
v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Реализация целевого дизайна (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/
2026-08-27 12:52:27 +07:00

325 lines
10 KiB
JavaScript

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