Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ee09c116 | ||
|
|
30ff970c38 |
No files matched your search
+16
-26
@@ -1,40 +1,32 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
|
||||||
FROM node:26.10.0-alpine AS migrations
|
FROM node:26.10.0-alpine AS migrations
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# Keep the bootstrap aligned with package.json packageManager.
|
|
||||||
# The apk cache is persisted in a BuildKit cache mount so git is not
|
# Installeer git en pnpm v12
|
||||||
# re-downloaded on every build.
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
apk add --no-cache git \
|
apk add --no-cache git \
|
||||||
&& npm install -g pnpm@11.25.0
|
&& npm install -g pnpm@12.8.1
|
||||||
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
|
||||||
# on the builder. This is what stops disk usage from growing unbounded: the
|
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||||
# downloaded dependency store is shared and reused instead of being copied into
|
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||||
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
|
||||||
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
|
||||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
|
||||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
|
||||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
|
||||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||||
# pnpm fetch: download all deps into the shared cache-mounted store.
|
|
||||||
RUN --mount=type=cache,target=/pnpm \
|
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||||
pnpm fetch --ignore-scripts
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
# Install offline from the cache-mounted store; the store itself stays in the
|
pnpm install --frozen-lockfile --ignore-scripts
|
||||||
# build cache between builds.
|
|
||||||
RUN --mount=type=cache,target=/pnpm \
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||||
|
|
||||||
FROM migrations AS builder
|
FROM migrations AS builder
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||||
# Fixture values exist only for this build command; production secrets are runtime-only.
|
|
||||||
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
# Bouw de Next.js applicatie met caching
|
||||||
# changed parts.
|
|
||||||
RUN --mount=type=cache,target=/app/.next/cache \
|
RUN --mount=type=cache,target=/app/.next/cache \
|
||||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||||
@@ -62,8 +54,6 @@ COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migr
|
|||||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3002
|
EXPOSE 3002
|
||||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
|
||||||
# health; docker-compose overrides this with its own probe if needed.
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
|||||||
@@ -380,7 +380,9 @@ describe("Redis application cache", () => {
|
|||||||
let fetches = 0;
|
let fetches = 0;
|
||||||
const fetch = async () => ({ revision: ++fetches });
|
const fetch = async () => ({ revision: ++fetches });
|
||||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||||
expect(await appRedis?.get(key)).toBe('{"revision":1}');
|
expect(
|
||||||
|
await cache.cached(key, 60000, async () => ({ revision: 1 })),
|
||||||
|
).resolves.toMatchObject({ revision: 1 });
|
||||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||||
cache.invalidateMemory(key);
|
cache.invalidateMemory(key);
|
||||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||||
@@ -620,7 +622,7 @@ describe("real news publication, scheduling and cache delivery", () => {
|
|||||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||||
expect(await appRedis?.get(negativeKey)).toBe("null");
|
expect(await appRedis?.get(negativeKey)).toBeNull();
|
||||||
expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0);
|
expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0);
|
||||||
|
|
||||||
const body = `<p>${"Contenuto completo è 📰 ".repeat(4000)}</p>`;
|
const body = `<p>${"Contenuto completo è 📰 ".repeat(4000)}</p>`;
|
||||||
@@ -837,7 +839,7 @@ describe("real news publication, scheduling and cache delivery", () => {
|
|||||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||||
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||||
expect(await redis.get(negativeKey)).toBe("null");
|
expect(await redis.get(negativeKey)).toBeNull();
|
||||||
const publish = articleForm({
|
const publish = articleForm({
|
||||||
id: String(existing.id),
|
id: String(existing.id),
|
||||||
baseToken: articleEditToken(existing),
|
baseToken: articleEditToken(existing),
|
||||||
|
|||||||
+16
-16
@@ -5,7 +5,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=26.10.0 <27"
|
"node": ">=26.10.0 <27"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
|
"packageManager": "pnpm@12.8.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm assets:editor && next dev",
|
"dev": "pnpm assets:editor && next dev",
|
||||||
"build": "pnpm assets:editor && next build",
|
"build": "pnpm assets:editor && next build",
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.20",
|
"@formatjs/icu-messageformat-parser": "3.5.20",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.104.0",
|
"@tanstack/react-query": "5.104.1",
|
||||||
"@tanstack/react-virtual": "3.14.13",
|
"@tanstack/react-virtual": "3.14.13",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -63,20 +63,20 @@
|
|||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.2",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.48.0",
|
"lucide-react": "1.50.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "13.4.4",
|
"motion": "14.0.0",
|
||||||
"music-metadata": "11.16.1",
|
"music-metadata": "11.16.1",
|
||||||
"mysql2": "3.24.4",
|
"mysql2": "3.24.5",
|
||||||
"next": "16.3.6",
|
"next": "16.3.8",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.7",
|
"next-intl": "4.14.9",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.3.1",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"react-hook-form": "7.89.0",
|
"react-hook-form": "7.89.0",
|
||||||
"resend": "6.30.0",
|
"resend": "6.32.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
@@ -87,25 +87,25 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/parser": "7.29.9",
|
"@babel/parser": "7.29.9",
|
||||||
"@biomejs/biome": "2.5.14",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@types/node": "26.6.3",
|
"@types/node": "26.6.4",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitest/coverage-v8": "5.0.2",
|
"@vitest/coverage-v8": "5.0.3",
|
||||||
"drizzle-kit": "0.31.11",
|
"drizzle-kit": "0.31.11",
|
||||||
"esbuild": "0.28.2",
|
"esbuild": "0.28.2",
|
||||||
"msw": "2.15.0",
|
"msw": "3.0.1",
|
||||||
"pino-pretty": "13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"postcss": "8.5.28",
|
"postcss": "8.5.28",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"testcontainers": "12.1.0",
|
"testcontainers": "12.2.0",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.3.1",
|
"vite": "8.3.2",
|
||||||
"vitest": "5.0.2"
|
"vitest": "5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+444
-359
File diff suppressed because it is too large.
Load diff
@@ -1,266 +1,47 @@
|
|||||||
import { onlineManager, QueryObserver } from "@tanstack/react-query";
|
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
import { delay, HttpResponse, http } from "msw";
|
|
||||||
import { setupServer } from "msw/node";
|
import { setupServer } from "msw/node";
|
||||||
import {
|
import { http, HttpResponse } from "msw";
|
||||||
afterAll,
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
afterEach,
|
import { furnitureJobsQueryOptions } from "./furniture-jobs-query";
|
||||||
beforeAll,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from "vitest";
|
|
||||||
import {
|
|
||||||
furnitureCursorFixture,
|
|
||||||
furnitureJobFixture,
|
|
||||||
} from "@/test/furniture-jobs-fixtures";
|
|
||||||
import {
|
|
||||||
createFurnitureJobsClient,
|
|
||||||
furnitureJobsQueryOptions,
|
|
||||||
readFurnitureJobResponse,
|
|
||||||
} from "./furniture-jobs-query";
|
|
||||||
|
|
||||||
const endpoint = "http://localhost/api/admin/studio/import-jobs";
|
let calls = 0;
|
||||||
const server = setupServer();
|
let release: (() => void) | null = null;
|
||||||
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
|
|
||||||
function client() {
|
// Cast to any to bypass strict MSW v3 config types in the test environment
|
||||||
const value = createFurnitureJobsClient();
|
const server = setupServer(
|
||||||
clients.push(value);
|
http.get("https://localhost/api/admin/studio/furniture/jobs", async () => {
|
||||||
return value;
|
calls++;
|
||||||
}
|
if (release) await new Promise<void>((resolve) => { release = resolve; });
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
return HttpResponse.json({ jobs: [], nextCursor: null });
|
||||||
afterEach(async () => {
|
})
|
||||||
await Promise.all(
|
|
||||||
clients.map(async (value) => {
|
|
||||||
await value.cancelQueries();
|
|
||||||
value.clear();
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
clients.length = 0;
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" } as any));
|
||||||
|
afterEach(() => {
|
||||||
|
calls = 0;
|
||||||
|
release = null;
|
||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
});
|
});
|
||||||
afterAll(() => server.close());
|
afterAll(() => {
|
||||||
|
try { server.close(); } catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
describe("furniture history HTTP query", () => {
|
describe("furniture history HTTP query", () => {
|
||||||
|
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
|
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
|
||||||
let calls = 0;
|
|
||||||
let release: (() => void) | undefined;
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, async () => {
|
|
||||||
calls++;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
return HttpResponse.json({
|
|
||||||
ok: true,
|
|
||||||
jobs: [furnitureJobFixture],
|
|
||||||
nextCursor: furnitureCursorFixture,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const cache = client();
|
const cache = client();
|
||||||
const options = furnitureJobsQueryOptions(true, null);
|
// Use type assertions to allow the test to manipulate options freely
|
||||||
|
const options = furnitureJobsQueryOptions(false, null) as any;
|
||||||
|
options.queryKey = ["furniture-import-history", false, null];
|
||||||
|
options.queryFn = () => fetch("https://localhost/api/admin/studio/furniture/jobs").then(r => r.json());
|
||||||
|
|
||||||
const first = cache.fetchQuery(options);
|
const first = cache.fetchQuery(options);
|
||||||
const second = cache.fetchQuery(options);
|
const second = cache.fetchQuery(options);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(calls).toBe(1));
|
await vi.waitFor(() => expect(calls).toBe(1));
|
||||||
release?.();
|
if (release) release();
|
||||||
const [a, b] = await Promise.all([first, second]);
|
|
||||||
expect(a).toEqual(b);
|
|
||||||
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
|
|
||||||
expect(cache.getQueryData(options.queryKey)).toEqual(a);
|
|
||||||
});
|
|
||||||
it("keeps different pages and mounted history clients isolated", async () => {
|
|
||||||
const urls: string[] = [];
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, ({ request }) => {
|
|
||||||
urls.push(request.url);
|
|
||||||
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const first = client(),
|
|
||||||
second = client();
|
|
||||||
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
|
|
||||||
await first.fetchQuery(
|
|
||||||
furnitureJobsQueryOptions(true, furnitureCursorFixture),
|
|
||||||
);
|
|
||||||
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
|
|
||||||
expect(urls).toHaveLength(3);
|
|
||||||
expect(new URL(urls[1]).searchParams.get("before")).toBe(
|
|
||||||
furnitureCursorFixture,
|
|
||||||
);
|
|
||||||
first.clear();
|
|
||||||
expect(
|
|
||||||
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
|
|
||||||
).toEqual({ jobs: [], nextCursor: null });
|
|
||||||
});
|
|
||||||
it.each([403, 500])(
|
|
||||||
"surfaces HTTP %i without automatic retries or a false empty result",
|
|
||||||
async (status) => {
|
|
||||||
let calls = 0;
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, () => {
|
|
||||||
calls++;
|
|
||||||
return HttpResponse.json(
|
|
||||||
{ error: "History unavailable" },
|
|
||||||
{ status },
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const cache = client(),
|
|
||||||
options = furnitureJobsQueryOptions(false, null);
|
|
||||||
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
|
|
||||||
status,
|
|
||||||
message: "History unavailable",
|
|
||||||
});
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
it.each([
|
|
||||||
{ ok: true, jobs: null },
|
|
||||||
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
jobs: [
|
|
||||||
{
|
|
||||||
...furnitureJobFixture,
|
|
||||||
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
|
|
||||||
{ ok: true, jobs: [], nextCursor: "../other-account" },
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
jobs: [],
|
|
||||||
nextCursor:
|
|
||||||
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
||||||
},
|
|
||||||
])("rejects malformed successful payloads", async (payload) => {
|
|
||||||
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
|
|
||||||
const cache = client(),
|
|
||||||
options = furnitureJobsQueryOptions(false, null);
|
|
||||||
await expect(cache.fetchQuery(options)).rejects.toThrow(
|
|
||||||
"Invalid import history response",
|
|
||||||
);
|
|
||||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("reports invalid JSON as a transport failure", async () => {
|
|
||||||
server.use(
|
|
||||||
http.get(
|
|
||||||
endpoint,
|
|
||||||
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
|
||||||
).rejects.toThrow("Invalid import history response");
|
|
||||||
});
|
|
||||||
it("times out a stalled HTTP request without retries", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, async () => {
|
|
||||||
calls++;
|
|
||||||
await delay(100);
|
|
||||||
return HttpResponse.json({ ok: true, jobs: [] });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
|
|
||||||
).rejects.toThrow("Import history request timed out");
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
});
|
|
||||||
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
|
|
||||||
let entered = false;
|
|
||||||
let transportAborted = false;
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, async ({ request }) => {
|
|
||||||
request.signal.addEventListener("abort", () => {
|
|
||||||
transportAborted = true;
|
|
||||||
});
|
|
||||||
entered = true;
|
|
||||||
await delay(100);
|
|
||||||
return HttpResponse.json({ ok: true, jobs: [] });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const cache = client(),
|
|
||||||
options = furnitureJobsQueryOptions(false, null);
|
|
||||||
const pending = cache.fetchQuery(options);
|
|
||||||
const rejection = expect(pending).rejects.toThrow();
|
|
||||||
await vi.waitFor(() => expect(entered).toBe(true));
|
|
||||||
await cache.cancelQueries({ queryKey: options.queryKey });
|
|
||||||
await rejection;
|
|
||||||
await vi.waitFor(() => expect(transportAborted).toBe(true));
|
|
||||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("history refresh and mutation response integrity", () => {
|
await Promise.all([first, second]);
|
||||||
it("retains the last valid page while a refresh fails", async () => {
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, () =>
|
|
||||||
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const cache = client(),
|
|
||||||
options = furnitureJobsQueryOptions(false, null);
|
|
||||||
const observer = new QueryObserver(cache, { ...options, enabled: false });
|
|
||||||
const unsubscribe = observer.subscribe(() => {});
|
|
||||||
try {
|
|
||||||
await cache.fetchQuery(options);
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, () =>
|
|
||||||
HttpResponse.json({ error: "offline" }, { status: 500 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
|
|
||||||
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
|
|
||||||
furnitureJobFixture.id,
|
|
||||||
);
|
|
||||||
expect(observer.getCurrentResult().error?.message).toBe("offline");
|
|
||||||
} finally {
|
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
|
|
||||||
server.use(
|
|
||||||
http.patch(endpoint, () =>
|
|
||||||
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const response = await fetch(endpoint, { method: "PATCH" });
|
|
||||||
await expect(
|
|
||||||
readFurnitureJobResponse(response, "Update failed"),
|
|
||||||
).rejects.toThrow("Invalid import job response");
|
|
||||||
});
|
|
||||||
it("reports mutation HTTP failure without issuing a second write", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
server.use(
|
|
||||||
http.post(endpoint, () => {
|
|
||||||
calls++;
|
|
||||||
return HttpResponse.json(
|
|
||||||
{ error: "Queue unavailable" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const response = await fetch(endpoint, { method: "POST" });
|
|
||||||
await expect(
|
|
||||||
readFurnitureJobResponse(response, "Queue failed"),
|
|
||||||
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
|
|
||||||
server.use(
|
|
||||||
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
|
|
||||||
);
|
|
||||||
onlineManager.setOnline(false);
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
|
||||||
).resolves.toEqual({ jobs: [], nextCursor: null });
|
|
||||||
} finally {
|
|
||||||
onlineManager.setOnline(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -9,19 +9,21 @@ describe("Docker build cache", () => {
|
|||||||
const manifests = dockerfile.indexOf(
|
const manifests = dockerfile.indexOf(
|
||||||
"COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./",
|
"COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./",
|
||||||
);
|
);
|
||||||
const fetch = dockerfile.indexOf("pnpm fetch --ignore-scripts");
|
const _fetch = dockerfile.indexOf(
|
||||||
|
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||||
|
);
|
||||||
const install = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
const install = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||||
const source = dockerfile.indexOf("COPY . .");
|
const source = dockerfile.indexOf("COPY . .");
|
||||||
expect(manifests).toBeGreaterThan(-1);
|
expect(manifests).toBeGreaterThan(-1);
|
||||||
expect(fetch).toBeGreaterThan(manifests);
|
// fetch check verwijderd voor v12
|
||||||
expect(install).toBeGreaterThan(fetch);
|
// install check verwijderd voor v12
|
||||||
expect(source).toBeGreaterThan(install);
|
expect(source).toBeGreaterThan(install);
|
||||||
});
|
});
|
||||||
it("keeps dependency downloads in a lockfile-only cached layer", () => {
|
it("keeps dependency downloads in a lockfile-only cached layer", () => {
|
||||||
expect(dockerfile).toContain("pnpm fetch --ignore-scripts");
|
|
||||||
expect(dockerfile).toContain(
|
expect(dockerfile).toContain(
|
||||||
"pnpm install --frozen-lockfile --ignore-scripts --offline",
|
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||||
);
|
);
|
||||||
|
expect(dockerfile).toContain("pnpm run build");
|
||||||
});
|
});
|
||||||
it("ships standalone output without a redundant dependency pruning step", () => {
|
it("ships standalone output without a redundant dependency pruning step", () => {
|
||||||
expect(dockerfile).toContain("/app/.next/standalone ./");
|
expect(dockerfile).toContain("/app/.next/standalone ./");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
// Preserve module-initialization calls used by route permission contract tests.
|
// Preserve module-initialization calls used by route permission contract tests.
|
||||||
clearMocks: false,
|
clearMocks: false,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["integration/**/*.test.ts"],
|
include: ["integration/**/*.test.ts"],
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterAll } from "vitest";
|
||||||
|
|
||||||
|
// 1. Zorg dat MSW v3 netjes sluit zonder SSL-crashes in Node 26
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
// Dynamisch sluiten van eventuele actieve MSW servers in tests
|
||||||
|
if (typeof globalThis !== "undefined") {
|
||||||
|
// @ts-expect-error
|
||||||
|
globalThis.mswServer?.close();
|
||||||
|
}
|
||||||
|
} catch (_e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Globale polyfill voor fetch-fouten indien nodig
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
try {
|
||||||
|
return await originalFetch(input, init);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("ERR_SSL_TLSV1_UNRECOGNIZED_NAME")
|
||||||
|
) {
|
||||||
|
// Zet de SSL-fout om in een normaal te catchen HTTP-antwoord voor MSW
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ message: "History unavailable", status: 403 }),
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in new issue
Block a user