feat(db): Datenmodell, erste Migration und Seed (#003)
Prisma 7.10 mit Postgres-Adapter, 23 Modelle inklusive Rechnungen mit Positionen, Seed fuer Administrator, Warnregeln und Demo-Daten, Testdatenbank fuer Vitest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testPrisma.$disconnect();
|
||||
});
|
||||
|
||||
it("verteilt eine Proxmox-Sammelrechnung auf mehrere Laufzeiten", async () => {
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Testkunde GmbH", kundennummer: "K-9001" },
|
||||
});
|
||||
|
||||
const hostDaten = [
|
||||
{ agentId: "test-1", hostname: "pve-test-01", key: "pve1c-aaaa111111" },
|
||||
{ agentId: "test-2", hostname: "pve-test-02", key: "pve1c-bbbb222222" },
|
||||
];
|
||||
|
||||
const rechnung = await testPrisma.proxmoxRechnung.create({
|
||||
data: {
|
||||
nummer: "PX-TEST-1",
|
||||
datum: new Date("2026-09-01"),
|
||||
faelligAm: new Date("2026-09-15"),
|
||||
nettoBetrag: 230,
|
||||
bezahlt: false,
|
||||
positionen: {
|
||||
create: hostDaten.map((h) => ({ bezeichnung: h.key, nettoBetrag: 115 })),
|
||||
},
|
||||
},
|
||||
include: { positionen: true },
|
||||
});
|
||||
|
||||
for (const [index, daten] of hostDaten.entries()) {
|
||||
const host = await testPrisma.host.create({
|
||||
data: {
|
||||
agentId: daten.agentId,
|
||||
hostname: daten.hostname,
|
||||
produkt: "PVE",
|
||||
kundeId: kunde.id,
|
||||
subKey: daten.key,
|
||||
subStatus: "ACTIVE",
|
||||
subNextduedate: new Date("2027-09-01"),
|
||||
},
|
||||
});
|
||||
|
||||
await testPrisma.laufzeit.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
key: daten.key,
|
||||
start: new Date("2026-09-01"),
|
||||
ende: new Date("2027-08-31"),
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPositionId: rechnung.positionen[index].id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const laufzeiten = await testPrisma.laufzeit.findMany({
|
||||
include: { proxmoxPosition: { include: { rechnung: true } } },
|
||||
});
|
||||
|
||||
expect(laufzeiten).toHaveLength(2);
|
||||
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
|
||||
expect(
|
||||
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
|
||||
).toBe(230);
|
||||
});
|
||||
|
||||
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Wegfallend GmbH", kundennummer: "K-9002" },
|
||||
});
|
||||
const host = await testPrisma.host.create({
|
||||
data: { agentId: "test-3", hostname: "pve-test-03", produkt: "PVE", kundeId: kunde.id },
|
||||
});
|
||||
|
||||
await testPrisma.kunde.delete({ where: { id: kunde.id } });
|
||||
|
||||
const danach = await testPrisma.host.findUnique({ where: { id: host.id } });
|
||||
expect(danach?.kundeId).toBeNull();
|
||||
});
|
||||
|
||||
it("verhindert zwei Laufzeiten mit gleichem Start je Host", async () => {
|
||||
const host = await testPrisma.host.create({
|
||||
data: { agentId: "test-4", hostname: "pve-test-04", produkt: "PBS" },
|
||||
});
|
||||
const daten = {
|
||||
hostId: host.id,
|
||||
key: "pbsc-1111111111",
|
||||
start: new Date("2026-01-01"),
|
||||
ende: new Date("2026-12-31"),
|
||||
};
|
||||
|
||||
await testPrisma.laufzeit.create({ data: daten });
|
||||
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { Client } from "pg";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Legt die Testdatenbank an (falls nötig) und spielt alle Migrationen ein.
|
||||
* Ohne `TEST_DATABASE_URL` werden die datenbankgestützten Tests übersprungen.
|
||||
*/
|
||||
export default async function setup() {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) {
|
||||
console.warn("TEST_DATABASE_URL ist nicht gesetzt – Datenbanktests werden übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const ziel = new URL(url);
|
||||
const datenbank = ziel.pathname.replace(/^\//, "");
|
||||
const verwaltung = new URL(url);
|
||||
verwaltung.pathname = "/postgres";
|
||||
|
||||
const client = new Client({ connectionString: verwaltung.toString() });
|
||||
await client.connect();
|
||||
const vorhanden = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [datenbank]);
|
||||
if (vorhanden.rowCount === 0) {
|
||||
// Datenbanknamen lassen sich nicht parametrisieren; er stammt aus der eigenen Konfiguration.
|
||||
await client.query(`CREATE DATABASE "${datenbank.replace(/"/g, "")}"`);
|
||||
}
|
||||
await client.end();
|
||||
|
||||
execFileSync("npx", ["prisma", "migrate", "deploy"], {
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import "dotenv/config";
|
||||
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
|
||||
/** Datenbankgestützte Tests laufen nur, wenn eine Testdatenbank konfiguriert ist. */
|
||||
export const datenbankVerfuegbar = Boolean(url);
|
||||
|
||||
export const testPrisma: PrismaClient = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5432/proxledger_test" }),
|
||||
});
|
||||
|
||||
/** Leert alle Tabellen; Reihenfolge spielt dank CASCADE keine Rolle. */
|
||||
export async function datenbankLeeren() {
|
||||
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
|
||||
`;
|
||||
if (tabellen.length === 0) return;
|
||||
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
|
||||
await testPrisma.$executeRawUnsafe(`TRUNCATE TABLE ${liste} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
Reference in New Issue
Block a user