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:
12
tests/e2e/cache-persistence.test.js
Normal file
12
tests/e2e/cache-persistence.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
29
tests/e2e/cache-prime.test.js
Normal file
29
tests/e2e/cache-prime.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
23
tests/e2e/cache-purge.test.js
Normal file
23
tests/e2e/cache-purge.test.js
Normal 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
69
tests/e2e/helpers.js
Normal 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}`);
|
||||
}
|
||||
30
tests/fixture/server.js
Normal file
30
tests/fixture/server.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const image = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGklEQVR4nGP8z8Dwn4ECwESJ5lEDRgYAtP4CHcB7d7gAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
Bun.serve({
|
||||
port: 8080,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/image.png") {
|
||||
return new Response(image, {
|
||||
headers: {
|
||||
"cache-control": "public, max-age=31536000",
|
||||
"content-length": String(image.length),
|
||||
"content-type": "image/png",
|
||||
etag: '"fixture-image-v1"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user