feat: добавить Image Gateway с кешем Souin

- добавлена сборка Caddy с Souin, Otter и NutsDB

- добавлена конфигурация dev, prod и test Docker Compose

- настроено кеширование через Otter L1 и NutsDB L2

- добавлены e2e-тесты Bun для кеша, restart и purge

- добавлена документация по запуску, API кеша и тестам
This commit is contained in:
2026-05-04 12:18:37 +03:00
commit 0751c4b469
26 changed files with 1608 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { expectHit, requestImage, waitForReady } from "./helpers.js";
describe("cache persistence", () => {
test("serves cached response from NutsDB after Caddy restart", async () => {
await waitForReady();
const hit = await requestImage(103);
expect(hit.status).toBe(200);
expectHit(hit.cacheStatus, "NUTS");
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import {
expectHit,
expectStored,
listKeys,
purgeAll,
requestImage,
waitForReady,
} from "./helpers.js";
describe("cache prime", () => {
test("stores responses in NutsDB and serves hot hits from Otter", async () => {
await waitForReady();
await purgeAll();
const miss = await requestImage(103);
expect(miss.status).toBe(200);
expectStored(miss.cacheStatus);
const hit = await requestImage(103);
expect(hit.status).toBe(200);
expectHit(hit.cacheStatus, "OTTER");
const keys = await listKeys();
expect(keys.some((key) => key.includes("/unsafe/resize:fit:103"))).toBe(
true,
);
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import {
expectHit,
expectStored,
purgeAll,
requestImage,
waitForReady,
} from "./helpers.js";
describe("cache purge", () => {
test("flush clears cache without breaking new writes", async () => {
await waitForReady();
await purgeAll();
const miss = await requestImage(103);
expect(miss.status).toBe(200);
expectStored(miss.cacheStatus);
const hit = await requestImage(103);
expect(hit.status).toBe(200);
expectHit(hit.cacheStatus, "OTTER");
});
});

69
tests/e2e/helpers.js Normal file
View File

@@ -0,0 +1,69 @@
import { expect } from "bun:test";
export const gatewayUrl = process.env.GATEWAY_URL ?? "http://caddy:8888";
export const adminUrl = process.env.ADMIN_URL ?? "http://caddy:2019";
export const sourceImageUrl =
process.env.SOURCE_IMAGE_URL ?? "http://fixture:8080/image.png";
export function imageRequestUrl(width = 103) {
return `${gatewayUrl}/unsafe/resize:fit:${width}:0:0/q:80/plain/${sourceImageUrl}`;
}
export async function waitForReady() {
const deadline = Date.now() + 30_000;
let lastError;
while (Date.now() < deadline) {
try {
const response = await fetch(`${adminUrl}/souin-api/souin/`);
if (response.ok) {
await response.arrayBuffer();
return;
}
lastError = new Error(`admin responded with ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Caddy admin API is not ready: ${lastError}`);
}
export async function purgeAll() {
const response = await fetch(`${adminUrl}/souin-api/souin/flush`, {
method: "PURGE",
});
await response.arrayBuffer();
expect(response.status).toBe(204);
}
export async function listKeys() {
const response = await fetch(`${adminUrl}/souin-api/souin/`);
expect(response.status).toBe(200);
return response.json();
}
export async function requestImage(width = 103) {
const response = await fetch(imageRequestUrl(width));
await response.arrayBuffer();
return {
cacheStatus: response.headers.get("cache-status") ?? "",
status: response.status,
};
}
export function expectStored(cacheStatus) {
expect(cacheStatus).toContain("fwd=uri-miss");
expect(cacheStatus).toContain("stored");
expect(cacheStatus).not.toContain("NUTS-INSERTION-ERROR");
}
export function expectHit(cacheStatus, storage) {
expect(cacheStatus).toContain("hit");
expect(cacheStatus).toContain(`detail=${storage}`);
}