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/
325 lines
10 KiB
JavaScript
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);
|
|
});
|
|
});
|