Merge pull request 'feat(p2): tests + health + metrics + CI' (#53) from feat/p2-tests-health-ci into main
Some checks failed
CI / test (push) Failing after 1m2s

This commit is contained in:
tarzzan 2026-06-01 02:27:16 +00:00
commit ccaad1d546
10 changed files with 2572 additions and 9 deletions

59
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,59 @@
name: CI
# Lance lint + typecheck + tests + build sur push/PR.
#
# Workflow dormant tant qu'aucun runner Forgejo n'est enregistré.
# Pour activer :
# 1) Sur git.cosmolan.fr, générer un token runner :
# Admin → Actions → Runners → Create new Runner Token
# (ou pour ce repo seul : Settings → Actions → Runners → Create)
# 2) Sur la machine d'exécution :
# wget https://codeberg.org/forgejo/runner/releases/download/v6.7.0/forgejo-runner-6.7.0-linux-amd64
# chmod +x forgejo-runner-6.7.0-linux-amd64
# ./forgejo-runner-6.7.0-linux-amd64 register \
# --instance https://git.cosmolan.fr \
# --token <TOKEN> \
# --name karbe-ci \
# --labels "ubuntu-latest:docker://node:20"
# 3) Démarrer :
# ./forgejo-runner-6.7.0-linux-amd64 daemon
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Generate Prisma client
run: npx prisma generate
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build (smoke)
run: npm run build
env:
# Stubs nécessaires au build statique — pas de connexion réelle.
DATABASE_URL: "postgresql://stub:stub@localhost:5432/stub?schema=public"
NEXTAUTH_SECRET: "ci-secret-not-for-production"
AUTH_SECRET: "ci-secret-not-for-production"
NEXT_PUBLIC_SITE_URL: "https://example.invalid"

2102
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,10 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1056.0",
@ -27,11 +30,13 @@
"@types/node": "^20.19.41",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^17.4.2",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
}

View file

@ -1,7 +1,101 @@
import { NextResponse } from "next/server";
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Probe = {
name: string;
ok: boolean;
latencyMs: number;
details?: string;
};
async function probeDb(): Promise<Probe> {
const t0 = Date.now();
try {
await prisma.$queryRaw`SELECT 1 AS ok`;
return { name: "database", ok: true, latencyMs: Date.now() - t0 };
} catch (e) {
return {
name: "database",
ok: false,
latencyMs: Date.now() - t0,
details: e instanceof Error ? e.message : String(e),
};
}
}
async function probeS3(): Promise<Probe> {
const t0 = Date.now();
const bucket = process.env.S3_BUCKET;
const endpoint = process.env.S3_ENDPOINT;
if (!bucket || !endpoint) {
return { name: "s3", ok: false, latencyMs: 0, details: "S3_BUCKET ou S3_ENDPOINT manquant" };
}
try {
const client = new S3Client({
endpoint,
region: process.env.S3_REGION ?? "us-east-1",
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "",
secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "",
},
});
await client.send(new HeadBucketCommand({ Bucket: bucket }));
return { name: "s3", ok: true, latencyMs: Date.now() - t0 };
} catch (e) {
return {
name: "s3",
ok: false,
latencyMs: Date.now() - t0,
details: e instanceof Error ? e.message : String(e),
};
}
}
function probeResend(): Probe {
return {
name: "resend",
ok: Boolean(process.env.RESEND_API_KEY?.trim()),
latencyMs: 0,
details: process.env.RESEND_API_KEY ? undefined : "RESEND_API_KEY non configuré (dry-run)",
};
}
function probeStripe(): Probe {
const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
const configured = key.length > 0 && !key.includes("REPLACE_ME");
return {
name: "stripe",
ok: configured,
latencyMs: 0,
details: configured ? undefined : "STRIPE_SECRET_KEY non configuré",
};
}
export async function GET() {
return NextResponse.json({ status: "ok" });
const t0 = Date.now();
const [db, s3] = await Promise.all([probeDb(), probeS3()]);
const resend = probeResend();
const stripe = probeStripe();
const probes = [db, s3, resend, stripe];
// DB est critique (503 si down). Le reste = non bloquant.
const critical = db.ok;
const status = critical ? 200 : 503;
return NextResponse.json(
{
status: critical ? "ok" : "degraded",
version: process.env.DEPLOYMENT_VERSION ?? "unknown",
uptimeSeconds: Math.round(process.uptime()),
latencyMs: Date.now() - t0,
probes,
},
{ status },
);
}

View file

@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { BookingStatus, CarbetStatus, UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Metrics publiques, agrégées (jamais de PII).
* Format JSON simple consommable par un script cron ou un dashboard léger.
*/
export async function GET() {
const now = new Date();
const last24h = new Date(now.getTime() - 86_400_000);
const last7d = new Date(now.getTime() - 7 * 86_400_000);
const last30d = new Date(now.getTime() - 30 * 86_400_000);
const [
carbetsPublished,
carbetsTotal,
bookings24h,
bookings7d,
bookings30d,
bookingsByStatus,
usersTotal,
usersByRole,
mediaTotal,
auditLast24h,
] = await Promise.all([
prisma.carbet.count({ where: { status: CarbetStatus.PUBLISHED } }),
prisma.carbet.count(),
prisma.booking.count({ where: { createdAt: { gte: last24h } } }),
prisma.booking.count({ where: { createdAt: { gte: last7d } } }),
prisma.booking.count({ where: { createdAt: { gte: last30d } } }),
prisma.booking.groupBy({
by: ["status"],
_count: { _all: true },
}),
prisma.user.count(),
prisma.user.groupBy({
by: ["role"],
_count: { _all: true },
}),
prisma.media.count(),
prisma.auditLog.count({ where: { createdAt: { gte: last24h } } }),
]);
return NextResponse.json({
generatedAt: now.toISOString(),
carbets: {
total: carbetsTotal,
published: carbetsPublished,
},
bookings: {
last24h: bookings24h,
last7d: bookings7d,
last30d: bookings30d,
byStatus: Object.fromEntries(
Object.values(BookingStatus).map((s) => [
s,
bookingsByStatus.find((b) => b.status === s)?._count._all ?? 0,
]),
),
},
users: {
total: usersTotal,
byRole: Object.fromEntries(
Object.values(UserRole).map((r) => [
r,
usersByRole.find((u) => u.role === r)?._count._all ?? 0,
]),
),
},
media: { total: mediaTotal },
audit: { last24h: auditLast24h },
});
}

107
tests/lib/booking.test.ts Normal file
View file

@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
DAY_MS,
enumerateUtcDays,
hasOverlap,
isPublicAllowedByDefaultPolicy,
isWeekendUtcDay,
normalizeUtcDayStart,
parseIsoDate,
} from "@/lib/booking";
describe("parseIsoDate", () => {
it("accepts ISO YYYY-MM-DD", () => {
const d = parseIsoDate("2026-06-15");
expect(d).toBeInstanceOf(Date);
expect(d?.toISOString().startsWith("2026-06-15")).toBe(true);
});
it("returns null for garbage input", () => {
expect(parseIsoDate("not a date")).toBeNull();
expect(parseIsoDate(null)).toBeNull();
expect(parseIsoDate(undefined)).toBeNull();
expect(parseIsoDate(123)).toBeNull();
});
});
describe("normalizeUtcDayStart", () => {
it("zeroes out time components", () => {
const d = new Date("2026-06-15T17:30:45.123Z");
const n = normalizeUtcDayStart(d);
expect(n.getUTCHours()).toBe(0);
expect(n.getUTCMinutes()).toBe(0);
expect(n.getUTCSeconds()).toBe(0);
expect(n.getUTCMilliseconds()).toBe(0);
expect(n.toISOString().slice(0, 10)).toBe("2026-06-15");
});
});
describe("hasOverlap", () => {
const d = (iso: string) => new Date(iso);
it("detects partial overlap", () => {
expect(
hasOverlap(d("2026-06-10"), d("2026-06-15"), d("2026-06-13"), d("2026-06-20")),
).toBe(true);
});
it("returns false for adjacent intervals (touching)", () => {
expect(
hasOverlap(d("2026-06-10"), d("2026-06-15"), d("2026-06-15"), d("2026-06-20")),
).toBe(false);
});
it("returns false for fully separate", () => {
expect(
hasOverlap(d("2026-06-01"), d("2026-06-05"), d("2026-06-10"), d("2026-06-15")),
).toBe(false);
});
it("returns true when one contains the other", () => {
expect(
hasOverlap(d("2026-06-01"), d("2026-06-30"), d("2026-06-10"), d("2026-06-15")),
).toBe(true);
});
});
describe("enumerateUtcDays", () => {
it("enumerates each day between start and end (exclusive)", () => {
const days = enumerateUtcDays(new Date("2026-06-10"), new Date("2026-06-13"));
expect(days.length).toBe(3);
expect(days[0].toISOString().slice(0, 10)).toBe("2026-06-10");
expect(days[2].toISOString().slice(0, 10)).toBe("2026-06-12");
});
it("returns empty when start === end", () => {
const days = enumerateUtcDays(new Date("2026-06-10"), new Date("2026-06-10"));
expect(days).toEqual([]);
});
});
describe("isWeekendUtcDay", () => {
it("flags Saturday (2026-06-13)", () => {
expect(isWeekendUtcDay(new Date("2026-06-13"))).toBe(true);
});
it("flags Sunday (2026-06-14)", () => {
expect(isWeekendUtcDay(new Date("2026-06-14"))).toBe(true);
});
it("rejects Monday (2026-06-15)", () => {
expect(isWeekendUtcDay(new Date("2026-06-15"))).toBe(false);
});
});
describe("isPublicAllowedByDefaultPolicy", () => {
it("blocks weekends by default (CE-priority policy)", () => {
expect(isPublicAllowedByDefaultPolicy(new Date("2026-06-13"))).toBe(false);
});
it("allows weekdays", () => {
expect(isPublicAllowedByDefaultPolicy(new Date("2026-06-15"))).toBe(true);
});
});
describe("DAY_MS constant", () => {
it("equals 86_400_000", () => {
expect(DAY_MS).toBe(86_400_000);
});
});

30
tests/lib/email.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock server-only avant l'import du module sous test (sinon ça jette).
vi.mock("server-only", () => ({}));
describe("sendEmail (dry-run sans RESEND_API_KEY)", () => {
beforeEach(() => {
delete process.env.RESEND_API_KEY;
vi.resetModules();
});
it("renvoie ok=true + reason=dry-run quand pas de clé", async () => {
const { sendEmail } = await import("@/lib/email");
const res = await sendEmail({
to: "test@example.com",
subject: "hello",
html: "<p>world</p>",
});
expect(res.ok).toBe(true);
expect(res.reason).toBe("dry-run");
});
it("n'écrit pas d'erreur quand pas de clé", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { sendEmail } = await import("@/lib/email");
await sendEmail({ to: "x@y.z", subject: "s", html: "h" });
expect(errSpy).not.toHaveBeenCalled();
errSpy.mockRestore();
});
});

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "@/lib/password";
describe("password hashing", () => {
it("round-trips a correct password", async () => {
const plain = "correct horse battery staple";
const hash = await hashPassword(plain);
expect(hash).not.toEqual(plain);
expect(hash.startsWith("$2")).toBe(true);
expect(await verifyPassword(plain, hash)).toBe(true);
});
it("rejects incorrect password", async () => {
const hash = await hashPassword("rightpass123");
expect(await verifyPassword("wrongpass", hash)).toBe(false);
});
it("produces different hashes for the same plaintext (salted)", async () => {
const plain = "samepw";
const a = await hashPassword(plain);
const b = await hashPassword(plain);
expect(a).not.toEqual(b);
expect(await verifyPassword(plain, a)).toBe(true);
expect(await verifyPassword(plain, b)).toBe(true);
});
});

50
tests/lib/reviews.test.ts Normal file
View file

@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import {
REVIEW_COMMENT_MAX,
REVIEW_HOST_RESPONSE_MAX,
REVIEW_RATING_MAX,
REVIEW_RATING_MIN,
formatAverageRating,
isValidRating,
} from "@/lib/reviews";
describe("rating constants", () => {
it("min=1 max=5", () => {
expect(REVIEW_RATING_MIN).toBe(1);
expect(REVIEW_RATING_MAX).toBe(5);
});
it("comment + host response caps are sensible", () => {
expect(REVIEW_COMMENT_MAX).toBeGreaterThan(0);
expect(REVIEW_HOST_RESPONSE_MAX).toBeGreaterThan(0);
});
});
describe("isValidRating", () => {
it("accepts integers 1-5", () => {
for (let i = REVIEW_RATING_MIN; i <= REVIEW_RATING_MAX; i++) {
expect(isValidRating(i)).toBe(true);
}
});
it("rejects out-of-range", () => {
expect(isValidRating(0)).toBe(false);
expect(isValidRating(6)).toBe(false);
expect(isValidRating(-1)).toBe(false);
});
it("rejects non-integers and non-numbers", () => {
expect(isValidRating(3.5)).toBe(false);
expect(isValidRating("3")).toBe(false);
expect(isValidRating(null)).toBe(false);
expect(isValidRating(undefined)).toBe(false);
});
});
describe("formatAverageRating", () => {
it("returns dash for null", () => {
expect(formatAverageRating(null)).toMatch(/—|-|n\/a/i);
});
it("formats a number with one decimal", () => {
const s = formatAverageRating(4.567);
expect(s).toMatch(/4[.,]/);
});
});

21
vitest.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
exclude: ["node_modules", ".next", "dist"],
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
include: ["src/lib/**/*.ts"],
exclude: ["src/lib/**/*.d.ts", "src/lib/admin/**", "src/lib/plugins/**"],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});