v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
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:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

324
test/api.test.js Normal file
View 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);
});
});

82
test/config.test.js Normal file
View File

@@ -0,0 +1,82 @@
"use strict";
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { ConfigError } = require("../src/errors");
describe("config", () => {
test("loads default configuration with generated password", () => {
const config = loadConfig({});
assert.equal(config.PORT, 3000);
assert.equal(config.APP_USER, "admin");
assert.equal(typeof config.APP_PASSWORD, "string");
assert.ok(config.APP_PASSWORD.length > 8);
assert.equal(config.isGeneratedPassword, true);
assert.equal(config.LOG_LEVEL, "info");
assert.equal(config.EDIT_MAX_DIMENSION, 1024);
assert.equal(config.UPSCALE_MAX_DIMENSION, 2048);
assert.equal(config.CODEX_CONCURRENCY, 1);
assert.equal(config.UPSCALE_CONCURRENCY, 2);
assert.equal(config.MAX_QUEUED_JOBS, 5);
assert.equal(config.SSE_ENABLED, true);
assert.equal(config.SESSION_PERSIST, true);
});
test("applies environment variable overrides", () => {
const env = {
PORT: "8080",
COMFY_URL: "http://10.0.0.5:8188",
APP_USER: "customuser",
APP_PASSWORD: "secretpassword123",
LOG_LEVEL: "debug",
EDIT_MAX_DIMENSION: "512",
CODEX_CONCURRENCY: "2",
UPSCALE_CONCURRENCY: "4",
MAX_QUEUED_JOBS: "10",
SSE_ENABLED: "false",
SESSION_PERSIST: "0",
TRUST_PROXY: "true",
};
const config = loadConfig(env);
assert.equal(config.PORT, 8080);
assert.equal(config.COMFY_URL, "http://10.0.0.5:8188");
assert.equal(config.APP_USER, "customuser");
assert.equal(config.APP_PASSWORD, "secretpassword123");
assert.equal(config.isGeneratedPassword, false);
assert.equal(config.LOG_LEVEL, "debug");
assert.equal(config.EDIT_MAX_DIMENSION, 512);
assert.equal(config.CODEX_CONCURRENCY, 2);
assert.equal(config.UPSCALE_CONCURRENCY, 4);
assert.equal(config.MAX_QUEUED_JOBS, 10);
assert.equal(config.SSE_ENABLED, false);
assert.equal(config.SESSION_PERSIST, false);
assert.equal(config.TRUST_PROXY, true);
});
test("throws ConfigError on invalid values", () => {
assert.throws(
() => loadConfig({ PORT: "99999" }),
(err) => err instanceof ConfigError && err.issues.length > 0
);
assert.throws(
() => loadConfig({ COMFY_URL: "not-a-url" }),
(err) => err instanceof ConfigError
);
assert.throws(
() => loadConfig({ LOG_LEVEL: "invalid_level" }),
(err) => err instanceof ConfigError
);
});
test("getSanitized() masks APP_PASSWORD", () => {
const config = loadConfig({ APP_PASSWORD: "supersecretpass" });
const sanitized = config.getSanitized();
assert.equal(sanitized.APP_PASSWORD, undefined);
assert.equal(sanitized.APP_PASSWORD_SET, true);
assert.equal(sanitized.APP_USER, "admin");
});
});

130
test/history-store.test.js Normal file
View File

@@ -0,0 +1,130 @@
"use strict";
const { test, describe, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
const { HistoryStore } = require("../src/core/history/history-store");
const { HistoryService, migrateLegacyHistory } = require("../src/core/history/history-service");
describe("history-store", () => {
let tempDir;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kadr-history-test-"));
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});
test("appends versions atomically and handles pruning", async () => {
const store = new HistoryStore({ dir: tempDir, limit: 3 });
const service = new HistoryService({ store });
const dummyImg = Buffer.from("dummy image content");
const v1 = await service.createVersion({
kind: "original",
label: "V1",
buffer: dummyImg,
ext: "jpg",
width: 100,
height: 100,
});
const v2 = await service.createVersion({
kind: "edit",
label: "V2",
parentId: v1.id,
buffer: dummyImg,
ext: "jpg",
width: 100,
height: 100,
});
const v3 = await service.createVersion({
kind: "edit",
label: "V3",
parentId: v2.id,
buffer: dummyImg,
ext: "jpg",
width: 100,
height: 100,
});
assert.equal(store.load().length, 3);
assert.ok(fs.existsSync(store.filePath(v1)));
assert.ok(fs.existsSync(store.filePath(v2)));
assert.ok(fs.existsSync(store.filePath(v3)));
// Appending 4th version should prune v1
const v4 = await service.createVersion({
kind: "upscale",
label: "V4",
parentId: v3.id,
buffer: dummyImg,
ext: "png",
width: 400,
height: 400,
});
const history = store.load();
assert.equal(history.length, 3);
assert.deepEqual(history.map((v) => v.id), [v2.id, v3.id, v4.id]);
// v1 file should be deleted on disk
assert.equal(fs.existsSync(store.filePath(v1)), false);
assert.ok(fs.existsSync(store.filePath(v4)));
// Check no leftover .tmp files
const tmpFiles = fs.readdirSync(tempDir).filter((f) => f.endsWith(".tmp"));
assert.equal(tmpFiles.length, 0);
});
test("cascade deletion removes version and all its children", async () => {
const store = new HistoryStore({ dir: tempDir, limit: 10 });
const service = new HistoryService({ store });
const dummyImg = Buffer.from("dummy image content");
const root = await service.createVersion({ kind: "original", label: "Root", buffer: dummyImg });
const child1 = await service.createVersion({ kind: "edit", label: "Child 1", parentId: root.id, buffer: dummyImg });
const child2 = await service.createVersion({ kind: "upscale", label: "Child 2", parentId: child1.id, buffer: dummyImg });
const independent = await service.createVersion({ kind: "original", label: "Independent", buffer: dummyImg });
assert.equal(store.load().length, 4);
// Delete child1 -> should delete child1 and child2, but keep root and independent
const result = await service.removeCascade(child1.id);
assert.equal(result.removed.length, 2);
assert.equal(result.remaining.length, 2);
assert.deepEqual(result.remaining.map((v) => v.id), [root.id, independent.id]);
// Verify deleted files are gone from disk
assert.ok(fs.existsSync(store.filePath(root)));
assert.ok(fs.existsSync(store.filePath(independent)));
assert.equal(fs.existsSync(store.filePath(child1)), false);
assert.equal(fs.existsSync(store.filePath(child2)), false);
});
test("migrateLegacyHistory copies files without deleting original", () => {
const legacyDir = path.join(tempDir, "legacy");
const targetDir = path.join(tempDir, "target");
fs.mkdirSync(legacyDir, { recursive: true });
const mockManifest = [{ id: "v-legacy-1", kind: "original", label: "Legacy" }];
fs.writeFileSync(path.join(legacyDir, "history.json"), JSON.stringify(mockManifest));
fs.writeFileSync(path.join(legacyDir, "v-legacy-1.jpg"), "fake jpg content");
migrateLegacyHistory(legacyDir, targetDir);
assert.ok(fs.existsSync(path.join(targetDir, "history.json")));
assert.ok(fs.existsSync(path.join(targetDir, "v-legacy-1.jpg")));
// Legacy files still exist
assert.ok(fs.existsSync(path.join(legacyDir, "history.json")));
assert.ok(fs.existsSync(path.join(legacyDir, "v-legacy-1.jpg")));
});
});

105
test/images.test.js Normal file
View File

@@ -0,0 +1,105 @@
"use strict";
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const sharp = require("sharp");
const { getImageDimensions, scaleForLimit } = require("../src/core/images/dimensions");
const { sniffImageType } = require("../src/core/images/sniff");
const { scaleRegion } = require("../src/core/codex/prompts");
const { downscaleImage } = require("../src/core/images/resize");
describe("images", () => {
test("sniffImageType recognizes JPEG, PNG, WebP and rejects invalid data", async () => {
// Generate valid test images via sharp
const pngBuffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 255, g: 0, b: 0 } },
}).png().toBuffer();
const jpegBuffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 255, b: 0 } },
}).jpeg().toBuffer();
const webpBuffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 0, b: 255 } },
}).webp().toBuffer();
assert.equal(sniffImageType(pngBuffer), "png");
assert.equal(sniffImageType(jpegBuffer), "jpeg");
assert.equal(sniffImageType(webpBuffer), "webp");
// HEIC ftyp box mock
const heicMock = Buffer.alloc(32);
heicMock.writeUInt32BE(32, 0);
heicMock.write("ftyp", 4, "ascii");
heicMock.write("heic", 8, "ascii");
assert.equal(sniffImageType(heicMock), "heic");
// Non-image garbage
assert.equal(sniffImageType(Buffer.from("not an image at all")), null);
assert.equal(sniffImageType(Buffer.alloc(4)), null);
});
test("getImageDimensions parses PNG, JPEG and WebP dimensions", async () => {
const pngBuffer = await sharp({
create: { width: 320, height: 240, channels: 3, background: "white" },
}).png().toBuffer();
const jpegBuffer = await sharp({
create: { width: 640, height: 480, channels: 3, background: "white" },
}).jpeg().toBuffer();
const webpBuffer = await sharp({
create: { width: 128, height: 96, channels: 3, background: "white" },
}).webp().toBuffer();
assert.deepEqual(getImageDimensions(pngBuffer), { width: 320, height: 240 });
assert.deepEqual(getImageDimensions(jpegBuffer), { width: 640, height: 480 });
assert.deepEqual(getImageDimensions(webpBuffer), { width: 128, height: 96 });
});
test("scaleRegion scales coordinates and preserves bounds", () => {
const region = { x: 100, y: 50, w: 200, h: 150 };
// factor = 1 -> identical
assert.deepEqual(scaleRegion(region, 1), region);
// factor = 0.5
assert.deepEqual(scaleRegion(region, 0.5), { x: 50, y: 25, w: 100, h: 75 });
// factor = 0.333
const scaled = scaleRegion(region, 0.333333);
assert.equal(scaled.x, 33);
assert.equal(scaled.y, 17);
assert.equal(scaled.w, 67);
assert.equal(scaled.h, 50);
// null region
assert.equal(scaleRegion(null, 0.5), null);
});
test("downscaleImage resizes images exceeding EDIT_MAX_DIMENSION", async () => {
const largeBuffer = await sharp({
create: { width: 2000, height: 1500, channels: 3, background: "red" },
}).jpeg().toBuffer();
const result = await downscaleImage(largeBuffer, 1024);
assert.equal(result.downscaled, true);
assert.equal(result.originalWidth, 2000);
assert.equal(result.originalHeight, 1500);
assert.equal(result.width, 1024);
assert.equal(result.height, 768);
assert.ok(result.factor < 1);
assert.ok(result.buffer.length < largeBuffer.length);
// Small image is NOT downscaled
const smallBuffer = await sharp({
create: { width: 800, height: 600, channels: 3, background: "blue" },
}).jpeg().toBuffer();
const smallResult = await downscaleImage(smallBuffer, 1024);
assert.equal(smallResult.downscaled, false);
assert.equal(smallResult.width, 800);
assert.equal(smallResult.height, 600);
assert.equal(smallResult.factor, 1);
});
});

161
test/job-queue.test.js Normal file
View File

@@ -0,0 +1,161 @@
"use strict";
const { test, describe, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
const { EventEmitter } = require("events");
const { JobStore } = require("../src/core/jobs/job-store");
const { Queue } = require("../src/core/jobs/queue");
const { EngineRegistry } = require("../src/core/engines/engine");
const { TooManyRequestsError } = require("../src/errors");
describe("job-queue", () => {
let tempDir;
let jobFile;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kadr-job-test-"));
jobFile = path.join(tempDir, "jobs.jsonl");
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});
test("processes jobs in FIFO order respecting group concurrency", async () => {
const jobStore = new JobStore({ file: jobFile });
const engineRegistry = new EngineRegistry();
const events = new EventEmitter();
const executed = [];
// Fake edit engine with delay
engineRegistry.register("edit", () => ({
async run(job) {
executed.push(`start:${job.id}`);
await new Promise((r) => setTimeout(r, 50));
executed.push(`done:${job.id}`);
return { ok: true };
},
}));
const queue = new Queue({
concurrencyByGroup: { codex: 1, "comfy-upscale": 2 },
maxQueuedJobs: 5,
jobStore,
engineRegistry,
engineContext: {},
events,
});
const job1 = { id: "job-1", type: "edit", status: "queued", payload: {} };
const job2 = { id: "job-2", type: "edit", status: "queued", payload: {} };
jobStore.saveJob(job1);
jobStore.saveJob(job2);
await queue.enqueue(job1);
const { queuePosition } = await queue.enqueue(job2);
assert.equal(queuePosition, 2);
queue.start();
// Wait until both jobs finish
while (jobStore.getJob("job-2")?.status !== "done") {
await new Promise((r) => setTimeout(r, 20));
}
// Because codex concurrency = 1, job-1 should complete before job-2 starts
assert.deepEqual(executed, [
"start:job-1",
"done:job-1",
"start:job-2",
"done:job-2",
]);
await queue.stop();
});
test("rejects with 429 when MAX_QUEUED_JOBS limit is exceeded", async () => {
const jobStore = new JobStore({ file: jobFile });
const engineRegistry = new EngineRegistry();
engineRegistry.register("edit", () => ({
run: async () => new Promise((r) => setTimeout(r, 500)),
}));
const queue = new Queue({
concurrencyByGroup: { codex: 1 },
maxQueuedJobs: 2,
jobStore,
engineRegistry,
engineContext: {},
});
const j1 = { id: "j1", type: "edit", status: "queued", payload: {} };
const j2 = { id: "j2", type: "edit", status: "queued", payload: {} };
const j3 = { id: "j3", type: "edit", status: "queued", payload: {} };
jobStore.saveJob(j1);
jobStore.saveJob(j2);
jobStore.saveJob(j3);
await queue.enqueue(j1);
await queue.enqueue(j2);
// 3rd job exceeds limit of 2
await assert.rejects(
async () => queue.enqueue(j3),
(err) => err instanceof TooManyRequestsError
);
});
test("recovers jobs from journal after server restart", () => {
// Write pre-existing jobs into jobs.jsonl
const lines = [
JSON.stringify({
seq: 1,
jobId: "j-codex-running",
at: new Date().toISOString(),
event: "created",
job: { id: "j-codex-running", type: "edit", status: "running", engine: "codex", attempts: 0 },
}),
JSON.stringify({
seq: 2,
jobId: "j-upscale-running",
at: new Date().toISOString(),
event: "created",
job: { id: "j-upscale-running", type: "upscale", status: "running", engine: "comfy-upscale", attempts: 0 },
}),
JSON.stringify({
seq: 3,
jobId: "j-queued",
at: new Date().toISOString(),
event: "created",
job: { id: "j-queued", type: "edit", status: "queued", engine: "codex", attempts: 0 },
}),
];
fs.writeFileSync(jobFile, lines.join("\n") + "\n", "utf8");
// Load JobStore -> recovery happens in constructor
const jobStore = new JobStore({ file: jobFile });
const codexJob = jobStore.getJob("j-codex-running");
assert.equal(codexJob.status, "interrupted");
assert.equal(codexJob.attempts, 1);
const upscaleJob = jobStore.getJob("j-upscale-running");
assert.equal(upscaleJob.status, "queued");
assert.equal(upscaleJob.attempts, 1);
const queuedJob = jobStore.getJob("j-queued");
assert.equal(queuedJob.status, "queued");
});
});

61
test/logger.test.js Normal file
View File

@@ -0,0 +1,61 @@
"use strict";
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { createLogger, singleLine, truncate } = require("../src/logger");
describe("logger", () => {
test("formats output as JSON-lines with timestamp, level and message", () => {
const lines = [];
const fakeStream = {
write: (str) => lines.push(str),
};
const logger = createLogger({ level: "debug", stream: fakeStream });
logger.info("Тестовое сообщение", { foo: "bar" });
assert.equal(lines.length, 1);
const parsed = JSON.parse(lines[0]);
assert.equal(parsed.level, "info");
assert.equal(parsed.msg, "Тестовое сообщение");
assert.equal(parsed.foo, "bar");
assert.ok(parsed.ts);
});
test("respects threshold and suppresses lower levels", () => {
const lines = [];
const fakeStream = { write: (str) => lines.push(str) };
const logger = createLogger({ level: "warn", stream: fakeStream });
logger.debug("Debug msg");
logger.info("Info msg");
assert.equal(lines.length, 0);
logger.warn("Warn msg");
logger.error("Error msg");
assert.equal(lines.length, 2);
});
test("sanitizes password, token and auth fields", () => {
const lines = [];
const fakeStream = { write: (str) => lines.push(str) };
const logger = createLogger({ level: "info", stream: fakeStream });
logger.info("Вход", {
password: "secretpassword",
sessionToken: "12345678abcdef",
cookieHeader: "kadr_session=xyz",
});
const parsed = JSON.parse(lines[0]);
assert.equal(parsed.password, "[REDACTED]");
assert.equal(parsed.sessionToken, "[REDACTED]");
assert.equal(parsed.cookieHeader, "[REDACTED]");
});
test("singleLine and truncate helpers work properly", () => {
assert.equal(singleLine(" hello \n\r\t world "), "hello world");
assert.equal(truncate("short", 10), "short");
assert.equal(truncate("123456789012345", 10), "123456789…");
});
});

72
test/prompts.test.js Normal file
View File

@@ -0,0 +1,72 @@
"use strict";
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const {
buildEditPrompt,
buildSuggestPrompt,
parseSuggestions,
parseRegion,
scaleRegion,
} = require("../src/core/codex/prompts");
const { AppError } = require("../src/errors");
describe("prompts", () => {
test("buildEditPrompt builds basic edit prompt", () => {
const prompt = buildEditPrompt({
prompt: "Сделай закатный свет",
dimensions: { width: 1024, height: 768 },
inputName: "input.jpg",
});
assert.ok(prompt.includes("Перед тобой фотография input.jpg. Примени к ней следующее редактирование: «Сделай закатный свет»."));
assert.ok(prompt.includes("Сохрани отредактированное фото в файл output.jpg"));
assert.ok(prompt.includes("Сохрани исходный размер 1024×768."));
});
test("buildEditPrompt includes reference, region and downscaled notes", () => {
const prompt = buildEditPrompt({
prompt: "Замени фон",
dimensions: { width: 800, height: 600 },
inputName: "input.jpg",
referenceName: "reference.jpg",
region: { x: 10, y: 20, w: 100, h: 200 },
downscaled: true,
});
assert.ok(prompt.includes("вспомогательное изображение reference.jpg"));
assert.ok(prompt.includes("Координаты области на reference.jpg: x=10, y=20, ширина=100, высота=200"));
assert.ok(prompt.includes("Исходное фото уменьшено для скорости обработки."));
assert.ok(prompt.includes("Сохрани исходный размер 800×600."));
});
test("parseSuggestions extracts up to 3 clean suggestions", () => {
const raw = `
1. Сделай теплый закатный свет
2. Добавь легкий туман на заднем плане
* Стилизуй под винтажное фото
4. Четвертый вариант, который должен быть отброшен
`;
const parsed = parseSuggestions(raw);
assert.equal(parsed.length, 3);
assert.equal(parsed[0], "Сделай теплый закатный свет");
assert.equal(parsed[1], "Добавь легкий туман на заднем плане");
assert.equal(parsed[2], "Стилизуй под винтажное фото");
});
test("parseRegion validates and parses region format", () => {
assert.equal(parseRegion(null), null);
assert.equal(parseRegion(""), null);
const fromObj = parseRegion({ x: 10.4, y: 20.8, w: 50, h: 60 });
assert.deepEqual(fromObj, { x: 10, y: 21, w: 50, h: 60 });
const fromStr = parseRegion(JSON.stringify({ x: 0, y: 0, w: 100, h: 100 }));
assert.deepEqual(fromStr, { x: 0, y: 0, w: 100, h: 100 });
// Invalid coordinates
assert.throws(() => parseRegion({ x: -5, y: 0, w: 10, h: 10 }), (err) => err instanceof AppError);
assert.throws(() => parseRegion({ x: 0, y: 0, w: 0, h: 10 }), (err) => err instanceof AppError);
assert.throws(() => parseRegion("invalid-json"), (err) => err instanceof AppError);
});
});