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