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
Some checks failed
CI / test (push) Failing after 1m2s
This commit is contained in:
commit
ccaad1d546
10 changed files with 2572 additions and 9 deletions
59
.forgejo/workflows/ci.yml
Normal file
59
.forgejo/workflows/ci.yml
Normal 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
2102
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
78
src/app/api/metrics/route.ts
Normal file
78
src/app/api/metrics/route.ts
Normal 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
107
tests/lib/booking.test.ts
Normal 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
30
tests/lib/email.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
tests/lib/password.test.ts
Normal file
27
tests/lib/password.test.ts
Normal 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
50
tests/lib/reviews.test.ts
Normal 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
21
vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue