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);
|
||||
});
|
||||
});
|
||||
82
test/config.test.js
Normal file
82
test/config.test.js
Normal 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
130
test/history-store.test.js
Normal 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
105
test/images.test.js
Normal 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
161
test/job-queue.test.js
Normal 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
61
test/logger.test.js
Normal 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
72
test/prompts.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user