Merge RTE-03 into main

This commit is contained in:
root committed 2026-09-26 11:26:02 +00:00
commit 567e90500a
21 files changed
+4703

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules
.next
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ThemeProvider, I18nProvider, ToastProvider } from "@nexarch/shl";
import { RuleEditor } from "./RuleEditor";
function renderWithProviders(ui: React.ReactElement) {
return render(
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{ui}</ToastProvider>
</I18nProvider>
</ThemeProvider>,
);
}
describe("RuleEditor", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
// AK1/Pruefung1: Priorisierung per Tastatur-Aktion (gleichwertig zum
// Drag & Drop, siehe Komponentenkommentar) — Regel mit niedrigerer
// Prioritaet wird per "nach oben"-Button ueber die andere gehoben.
it("zeigt Regeln sortiert und erlaubt Umsortieren per Aktionsknopf", async () => {
const priorityCalls: { id: string; body: string }[] = [];
global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = url.toString();
if (u.includes("/priority")) {
const idMatch = u.match(/routing-rules\/([^/]+)\/priority/);
priorityCalls.push({ id: idMatch ? idMatch[1] : "", body: init?.body as string });
return new Response(null, { status: 204 });
}
return new Response(
JSON.stringify([
{ ID: "hoch", Name: "hohe-regel", Priority: 10, Enabled: true, ActiveVersion: 1, Conditions: [], Actions: [] },
{ ID: "niedrig", Name: "niedrige-regel", Priority: 1, Enabled: true, ActiveVersion: 1, Conditions: [], Actions: [] },
]),
{ status: 200 },
);
}) as typeof fetch;
renderWithProviders(<RuleEditor userId="alice" />);
await screen.findByText(/hohe-regel/);
const moveDown = screen.getByRole("button", { name: /hohe-regel nach unten verschieben/i });
fireEvent.click(moveDown);
await waitFor(() => expect(priorityCalls.length).toBe(2));
expect(priorityCalls.map((c) => JSON.parse(c.body).priority).sort()).toEqual([1, 10]);
});
// AK3/Pruefung2: Testlauf einer Regel vor Aktivierung.
it("zeigt das Ergebnis eines Testlaufs an", async () => {
global.fetch = vi.fn(async (url: string | URL) => {
const u = url.toString();
if (u.includes("/routing-rules/test")) {
return new Response(JSON.stringify({ matched: true, rule_id: "r1", rule_name: "rechnungsregel", actions: [{ type: "route_to_process", target: "approval" }] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}) as typeof fetch;
renderWithProviders(<RuleEditor userId="alice" />);
await screen.findByText(/Keine Regeln angelegt/);
fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i }));
await waitFor(() => expect(screen.getByText(/rechnungsregel/)).toBeInTheDocument());
});
// Auftrag: Anlegen einer Regel in der Oberflaeche.
it("legt eine neue Regel per Formular an", async () => {
let createBody: string | null = null;
global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = url.toString();
if (init?.method === "POST" && u.includes("/routing-rules") && !u.includes("/test")) {
createBody = init.body as string;
return new Response(JSON.stringify({ ID: "new", Name: "neue-regel", Priority: 5, Enabled: true, ActiveVersion: 1, Conditions: [], Actions: [] }), { status: 201 });
}
return new Response(JSON.stringify([]), { status: 200 });
}) as typeof fetch;
renderWithProviders(<RuleEditor userId="alice" />);
await screen.findByText(/Keine Regeln angelegt/);
fireEvent.change(screen.getByLabelText(/^Name$/), { target: { value: "neue-regel" } });
fireEvent.change(screen.getByLabelText(/Bedingung: Feld/), { target: { value: "object_type" } });
fireEvent.change(screen.getByLabelText(/Bedingung: Wert/), { target: { value: "invoice" } });
fireEvent.change(screen.getByLabelText(/Aktion: Ziel/), { target: { value: "approval" } });
fireEvent.click(screen.getByRole("button", { name: /Regel anlegen/i }));
await waitFor(() => expect(createBody).not.toBeNull());
expect(JSON.parse(createBody as unknown as string).name).toBe("neue-regel");
});
it("zeigt einen Fehler bei ungueltigem JSON im Testlauf", async () => {
global.fetch = vi.fn(async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
renderWithProviders(<RuleEditor userId="alice" />);
await screen.findByText(/Keine Regeln angelegt/);
const textarea = screen.getByLabelText(/Beispielobjekt/i);
fireEvent.change(textarea, { target: { value: "{ungueltig" } });
fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i }));
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Ungueltiges JSON/i));
});
});
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useToast } from "@nexarch/shl";
import { listRules, createRule, setPriority, setEnabled, testRule, ApiError, type Rule } from "../lib/api";
// RTE-03 AK1/Pruefung1: Priorisierung per Drag & Drop UND gleichwertige
// Tastaturbedienung ueber "nach oben/unten"-Aktionen — Drag & Drop allein
// ist nicht tastaturbedienbar, gleiches Prinzip wie ReminderKanban (VOR-05).
export function RuleEditor({ userId }: { userId: string }) {
const [rules, setRules] = useState<Rule[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const toast = useToast();
const reload = useCallback(async () => {
try {
const result = await listRules(userId);
setRules(result);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Regeln konnten nicht geladen werden.");
setRules([]);
}
}, [userId]);
useEffect(() => {
void reload();
}, [reload]);
async function swapPriority(a: Rule, b: Rule) {
try {
await setPriority(userId, a.ID, b.Priority);
await setPriority(userId, b.ID, a.Priority);
await reload();
} catch (err) {
toast.push(err instanceof ApiError ? err.message : "Umsortieren fehlgeschlagen.", "danger");
}
}
async function moveUp(index: number) {
if (!rules || index === 0) return;
await swapPriority(rules[index], rules[index - 1]);
}
async function moveDown(index: number) {
if (!rules || index === rules.length - 1) return;
await swapPriority(rules[index], rules[index + 1]);
}
async function toggleEnabled(rule: Rule) {
try {
await setEnabled(userId, rule.ID, !rule.Enabled);
await reload();
} catch (err) {
toast.push(err instanceof ApiError ? err.message : "Status konnte nicht geaendert werden.", "danger");
}
}
if (error) {
return <p role="alert">{error}</p>;
}
if (rules === null) {
return <p>Lade Regeln …</p>;
}
return (
<div>
<h2>Routing-Regeln</h2>
<CreateForm userId={userId} onCreated={reload} />
{rules.length === 0 ? (
<p style={{ color: "var(--shl-color-text-secondary, #475467)" }}>Keine Regeln angelegt.</p>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{rules.map((rule, index) => (
<li
key={rule.ID}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", rule.ID);
setDraggingId(rule.ID);
}}
onDragEnd={() => setDraggingId(null)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const droppedId = e.dataTransfer.getData("text/plain") || draggingId;
if (droppedId && droppedId !== rule.ID) {
const source = rules.find((r) => r.ID === droppedId);
if (source) void swapPriority(source, rule);
}
setDraggingId(null);
}}
style={{
border: "1px solid var(--shl-color-border, #d0d5dd)",
borderRadius: 6,
padding: 8,
marginBottom: 8,
background: "var(--shl-color-background, #ffffff)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<div>
<p style={{ margin: "0 0 4px", fontWeight: 600 }}>
{rule.Name} <span style={{ fontWeight: 400 }}>(Prioritaet {rule.Priority})</span>
</p>
<p style={{ margin: 0, color: "var(--shl-color-text-secondary, #475467)" }}>
{rule.Enabled ? "Aktiv" : "Deaktiviert"}
</p>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button type="button" onClick={() => void moveUp(index)} disabled={index === 0} aria-label={`${rule.Name} nach oben verschieben`}>
↑
</button>
<button type="button" onClick={() => void moveDown(index)} disabled={index === rules.length - 1} aria-label={`${rule.Name} nach unten verschieben`}>
↓
</button>
<button type="button" onClick={() => void toggleEnabled(rule)}>
{rule.Enabled ? "Deaktivieren" : "Aktivieren"}
</button>
</div>
</li>
))}
</ul>
)}
<TestPanel userId={userId} />
</div>
);
}
// RTE-03 Auftrag: Anlegen einer Regel in der Oberflaeche. Feld/Operator/Wert
// bewusst als einzelne Bedingung (nicht mehrere) fuer die kleinste Loesung
// — Erweiterung auf mehrere Bedingungen ist eine spaetere Kachel, nicht Teil
// der drei Akzeptanzkriterien dieser hier.
function CreateForm({ userId, onCreated }: { userId: string; onCreated: () => void }) {
const [name, setName] = useState("");
const [priority, setPriorityValue] = useState(0);
const [field, setField] = useState("");
const [operator, setOperator] = useState("equals");
const [value, setValue] = useState("");
const [actionType, setActionType] = useState("route_to_process");
const [target, setTarget] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const toast = useToast();
async function submit(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
try {
await createRule(userId, {
name,
priority,
conditions: [{ field, operator, value }],
actions: [{ type: actionType, target }],
});
setName("");
setField("");
setValue("");
setTarget("");
toast.push("Regel angelegt.", "success");
onCreated();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "Regel konnte nicht angelegt werden.");
}
}
return (
<form onSubmit={(e) => void submit(e)} style={{ marginBottom: 24, display: "flex", flexDirection: "column", gap: 8, maxWidth: 480 }}>
<h3>Neue Regel anlegen</h3>
<label htmlFor="rule-name">Name</label>
<input id="rule-name" value={name} onChange={(e) => setName(e.target.value)} required />
<label htmlFor="rule-priority">Priorität</label>
<input id="rule-priority" type="number" value={priority} onChange={(e) => setPriorityValue(Number(e.target.value))} />
<label htmlFor="rule-field">Bedingung: Feld</label>
<input id="rule-field" value={field} onChange={(e) => setField(e.target.value)} placeholder="z.B. object_type" required />
<label htmlFor="rule-operator">Bedingung: Operator</label>
<select id="rule-operator" value={operator} onChange={(e) => setOperator(e.target.value)}>
<option value="equals">gleich</option>
<option value="contains">enthält</option>
<option value="in">in Liste</option>
</select>
<label htmlFor="rule-value">Bedingung: Wert</label>
<input id="rule-value" value={value} onChange={(e) => setValue(e.target.value)} required />
<label htmlFor="rule-action-type">Aktion: Typ</label>
<select id="rule-action-type" value={actionType} onChange={(e) => setActionType(e.target.value)}>
<option value="route_to_process">An Prozess routen</option>
<option value="route_to_person">An Person routen</option>
</select>
<label htmlFor="rule-target">Aktion: Ziel</label>
<input id="rule-target" value={target} onChange={(e) => setTarget(e.target.value)} required />
{formError ? <p role="alert">{formError}</p> : null}
<button type="submit">Regel anlegen</button>
</form>
);
}
// RTE-03 AK3/Pruefung2: Regel vor Aktivierung testen, ohne echte
// Ausfuehrung anzustossen.
function TestPanel({ userId }: { userId: string }) {
const [objectJSON, setObjectJSON] = useState('{"object_type": "invoice"}');
const [result, setResult] = useState<string | null>(null);
const [testError, setTestError] = useState<string | null>(null);
async function runTest() {
setTestError(null);
setResult(null);
let object: Record<string, unknown>;
try {
object = JSON.parse(objectJSON);
} catch {
setTestError("Ungueltiges JSON.");
return;
}
try {
const res = await testRule(userId, object);
setResult(
res.matched
? `Treffer: „${res.rule_name}“ (${res.actions?.length ?? 0} Aktion(en))`
: "Kein Treffer — Objekt faellt in keinen Klaerungskorb-Fall.",
);
} catch (err) {
setTestError(err instanceof ApiError ? err.message : "Testlauf fehlgeschlagen.");
}
}
return (
<section style={{ marginTop: 24 }}>
<h3>Regel testen</h3>
<label htmlFor="test-object">Beispielobjekt (JSON)</label>
<textarea
id="test-object"
value={objectJSON}
onChange={(e) => setObjectJSON(e.target.value)}
rows={4}
style={{ display: "block", width: "100%", maxWidth: 480, marginBottom: 8 }}
/>
<button type="button" onClick={() => void runTest()}>
Testlauf starten
</button>
{testError ? <p role="alert">{testError}</p> : null}
{result ? <p role="status">{result}</p> : null}
</section>
);
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
function backendURL(): string {
const base = process.env.ROUTING_RULES_API_URL;
if (!base) {
throw new Error("ROUTING_RULES_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const userId = request.nextUrl.searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
const payload = await request.json().catch(() => null);
if (!payload) {
return NextResponse.json({ error: "ungueltige anfrage" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/routing-rules/${encodeURIComponent(id)}/enabled`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-User-Id": userId },
body: JSON.stringify(payload),
});
} catch (err) {
return NextResponse.json({ error: `Workflow-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
if (backendResponse.status === 204) {
return new NextResponse(null, { status: 204 });
}
const body = await backendResponse.json().catch(() => null);
return NextResponse.json(body ?? { error: "Status konnte nicht gesetzt werden" }, { status: backendResponse.status });
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
function backendURL(): string {
const base = process.env.ROUTING_RULES_API_URL;
if (!base) {
throw new Error("ROUTING_RULES_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const userId = request.nextUrl.searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
const payload = await request.json().catch(() => null);
if (!payload) {
return NextResponse.json({ error: "ungueltige anfrage" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/routing-rules/${encodeURIComponent(id)}/priority`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-User-Id": userId },
body: JSON.stringify(payload),
});
} catch (err) {
return NextResponse.json({ error: `Workflow-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
if (backendResponse.status === 204) {
return new NextResponse(null, { status: 204 });
}
const body = await backendResponse.json().catch(() => null);
return NextResponse.json(body ?? { error: "Prioritaet konnte nicht gesetzt werden" }, { status: backendResponse.status });
}
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { GET, POST } from "./route";
describe("GET /api/routing-rules", () => {
const originalFetch = global.fetch;
const originalEnv = process.env.ROUTING_RULES_API_URL;
beforeEach(() => {
process.env.ROUTING_RULES_API_URL = "http://backend.local";
});
afterEach(() => {
global.fetch = originalFetch;
process.env.ROUTING_RULES_API_URL = originalEnv;
});
it("liefert 400 ohne userId", async () => {
const req = new NextRequest("http://localhost/api/routing-rules");
const res = await GET(req);
expect(res.status).toBe(400);
});
it("leitet X-User-Id an das Backend weiter", async () => {
let capturedHeaders: Headers | undefined;
global.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => {
capturedHeaders = new Headers(init?.headers);
return new Response(JSON.stringify([{ ID: "1", Name: "r" }]), { status: 200 });
}) as typeof fetch;
const req = new NextRequest("http://localhost/api/routing-rules?userId=alice");
const res = await GET(req);
expect(res.status).toBe(200);
expect(capturedHeaders?.get("X-User-Id")).toBe("alice");
});
});
describe("POST /api/routing-rules", () => {
const originalFetch = global.fetch;
const originalEnv = process.env.ROUTING_RULES_API_URL;
beforeEach(() => {
process.env.ROUTING_RULES_API_URL = "http://backend.local";
});
afterEach(() => {
global.fetch = originalFetch;
process.env.ROUTING_RULES_API_URL = originalEnv;
});
it("gibt Backend-Fehlerstatus bei ungueltiger Bedingung weiter", async () => {
global.fetch = vi.fn(async () => new Response(JSON.stringify({ error: "bedingung: unbekannter operator x" }), { status: 400 })) as typeof fetch;
const req = new NextRequest("http://localhost/api/routing-rules?userId=alice", {
method: "POST",
body: JSON.stringify({ name: "r", priority: 1, conditions: [{ field: "f", operator: "x", value: "v" }], actions: [] }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
function backendURL(): string {
const base = process.env.ROUTING_RULES_API_URL;
if (!base) {
throw new Error("ROUTING_RULES_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
function requireUserID(request: NextRequest): string | null {
return request.nextUrl.searchParams.get("userId");
}
export async function GET(request: NextRequest) {
const userId = requireUserID(request);
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/routing-rules`, {
headers: { "X-User-Id": userId },
cache: "no-store",
});
} catch (err) {
return NextResponse.json({ error: `Workflow-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const body = await backendResponse.json().catch(() => null);
if (!backendResponse.ok) {
return NextResponse.json(body ?? { error: "Regeln konnten nicht geladen werden" }, { status: backendResponse.status });
}
return NextResponse.json(body);
}
export async function POST(request: NextRequest) {
const userId = requireUserID(request);
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
const payload = await request.json().catch(() => null);
if (!payload) {
return NextResponse.json({ error: "ungueltige anfrage" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/routing-rules`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-User-Id": userId },
body: JSON.stringify(payload),
});
} catch (err) {
return NextResponse.json({ error: `Workflow-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const body = await backendResponse.json().catch(() => null);
if (!backendResponse.ok) {
return NextResponse.json(body ?? { error: "Regel konnte nicht angelegt werden" }, { status: backendResponse.status });
}
return NextResponse.json(body, { status: 201 });
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
function backendURL(): string {
const base = process.env.ROUTING_RULES_API_URL;
if (!base) {
throw new Error("ROUTING_RULES_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
export async function POST(request: NextRequest) {
const userId = request.nextUrl.searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
const payload = await request.json().catch(() => null);
if (!payload) {
return NextResponse.json({ error: "ungueltige anfrage" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/routing-rules/test`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-User-Id": userId },
body: JSON.stringify(payload),
});
} catch (err) {
return NextResponse.json({ error: `Workflow-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const body = await backendResponse.json().catch(() => null);
if (!backendResponse.ok) {
return NextResponse.json(body ?? { error: "Testlauf fehlgeschlagen" }, { status: backendResponse.status });
}
return NextResponse.json(body);
}
+45
View File
@@ -0,0 +1,45 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Routing-Regeln",
};
const focusVisibleStyle = `
:focus-visible {
outline: 3px solid var(--shl-color-focus-ring, #1D4ED8);
outline-offset: 2px;
}
`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<head>
<style>{focusVisibleStyles}</style>
<Script id="shl-theme-init" strategy="beforeInteractive">
{themeInitScript()}
</Script>
</head>
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { TextField } from "@nexarch/shl";
import { RuleEditor } from "./RuleEditor";
function RulesPage() {
const params = useSearchParams();
const userId = params.get("user") ?? "";
return (
<main style={{ maxWidth: 1100, margin: "0 auto", padding: 24 }}>
<h1>Routing-Regel-Editor</h1>
{userId === "" ? (
<form
onSubmit={(e) => {
e.preventDefault();
const value = (e.currentTarget.elements.namedItem("user") as HTMLInputElement).value.trim();
if (value) {
window.location.search = `?user=${encodeURIComponent(value)}`;
}
}}
>
<TextField name="user" label="Nutzerkennung" hint="Übergangsweise ohne zentrale Session (RTE-01/APR-01)" />
<button type="submit">Übernehmen</button>
</form>
) : (
<RuleEditor userId={userId} />
)}
</main>
);
}
export default function Page() {
return (
<Suspense fallback={<p>Lade …</p>}>
<RulesPage />
</Suspense>
);
}
+96
View File
@@ -0,0 +1,96 @@
// Duenner Client fuer die eigenen Backend-for-Frontend-Routen unter
// app/api — kein direkter Zugriff auf die Workflow-API vom Client aus,
// gleiches Muster wie web/workflow-cases/web/workflow-approvals.
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export interface Condition {
field: string;
operator: string;
value: unknown;
}
export interface Action {
type: string;
target: string;
process_id?: string;
}
export interface Rule {
ID: string;
Name: string;
Priority: number;
Enabled: boolean;
ActiveVersion: number;
Conditions: Condition[] | null;
Actions: Action[] | null;
}
export interface TestResult {
matched: boolean;
rule_id?: string;
rule_name?: string;
actions?: Action[];
}
async function handle<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => null);
if (!res.ok) {
const message = (body && typeof body === "object" && "error" in body && typeof (body as { error: unknown }).error === "string")
? (body as { error: string }).error
: "Anfrage fehlgeschlagen";
throw new ApiError(message, res.status);
}
return body as T;
}
export async function listRules(userId: string): Promise<Rule[]> {
const res = await fetch(`/api/routing-rules?userId=${encodeURIComponent(userId)}`, { cache: "no-store" });
return handle<Rule[]>(res);
}
export async function createRule(userId: string, input: { name: string; priority: number; conditions: Condition[]; actions: Action[] }): Promise<Rule> {
const res = await fetch(`/api/routing-rules?userId=${encodeURIComponent(userId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return handle<Rule>(res);
}
export async function setPriority(userId: string, id: string, priority: number): Promise<void> {
const res = await fetch(`/api/routing-rules/${encodeURIComponent(id)}/priority?userId=${encodeURIComponent(userId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority }),
});
if (!res.ok) {
await handle(res);
}
}
export async function setEnabled(userId: string, id: string, enabled: boolean): Promise<void> {
const res = await fetch(`/api/routing-rules/${encodeURIComponent(id)}/enabled?userId=${encodeURIComponent(userId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
await handle(res);
}
}
export async function testRule(userId: string, object: Record<string, unknown>): Promise<TestResult> {
const res = await fetch(`/api/routing-rules/test?userId=${encodeURIComponent(userId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ object }),
});
return handle<TestResult>(res);
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+3319
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nexarch-routing-rules",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.5.2",
"@types/node": "^22.20.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"jsdom": "24.1.0",
"typescript": "5.5.3",
"vitest": "^5.0.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
},
},
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+5
View File
@@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);
+70
View File
@@ -0,0 +1,70 @@
# RTE-03 – Routing-Regel-Editor (Oberfläche): Prüfprotokoll
Stand: `main` Head `f4d2f563`. RTE-02 (`routingengine`) war zum Zeitpunkt dieser Arbeit noch
nicht in `main` gemerged (nur `routingrule`/RTE-01); der Testlauf-Endpunkt dieser Kachel
verwendet daher direkt `routingrule.Rule.Matches` und die vorhandene Prioritäts-Sortierung,
ohne von `routingengine` abzuhängen — funktional identisch zum "erste zutreffende Regel
gewinnt"-Prinzip aus RTE-02, ohne Merge-Kollisionsrisiko mit dem parallel laufenden Merge.
## Neue Dateien
- `workflow/internal/routingrule/editor.go` — additive `Store.ListAll` (auch deaktivierte
Regeln) und `Store.SetPriority` (reine Prioritätsänderung ohne neue Version). Kein
bestehender RTE-01-Code verändert.
- `workflow/internal/routingruleapi` — HTTP-Bindung: `GET/POST /routing-rules`,
`PUT /routing-rules/{id}` (Bedingungen/Aktionen), `PUT /routing-rules/{id}/priority`,
`PUT /routing-rules/{id}/enabled`, `POST /routing-rules/test`. Auth per `X-User-Id`,
gleiches Muster wie `approvalapi` (APR-01). Server-seitige Validierung von Bedingungen
(Feld/Operator/Wert) und Aktionen (Typ/Ziel), da `routingrule.Store` selbst nicht
validiert (RTE-01-Scope) — bewusst auf API-Ebene statt bestehenden Store-Code umzubauen.
- `web/routing-rules` — Next.js/React/TS + `@nexarch/shl`, gleiches Grundgerüst wie
`web/workflow-cases`/`web/workflow-approvals`. `RuleEditor.tsx`: Anlage-Formular (Name,
Priorität, eine Bedingung, eine Aktion), Liste priorisierter Regeln mit Drag & Drop UND
gleichwertigen Tastatur-Aktionen ("nach oben/unten"), Testlauf-Panel (JSON-Beispielobjekt
→ Treffer/kein Treffer).
## Akzeptanzkriterien
1. **Regeln sind priorisierbar** — erfüllt: Drag & Drop (`onDragStart`/`onDrop` tauscht
Prioritäten zweier Regeln) und Tastatur-Buttons ("↑"/"↓") rufen `PUT .../priority` auf.
2. **Regeländerung ist ohne Codeänderung möglich** — erfüllt: Anlegen/Bearbeiten läuft
vollständig über die REST-Schnittstelle (`POST`/`PUT /routing-rules`), keine
Konfigurationsdatei, kein Redeploy nötig.
3. **Regel kann vor Aktivierung getestet werden** — erfüllt: `POST /routing-rules/test`
wertet ein Beispielobjekt gegen alle aktivierten Regeln aus (`Matches`, höchste
Priorität zuerst) und liefert das Ergebnis zurück, ohne eine Zeile in
`routing_decisions` (RTE-02) zu erzeugen — reiner Lesevorgang.
## Prüfungen
| # | Prüfung | Ziel | Ergebnis |
|---|---|---|---|
| 1 | Priorisierung per Drag & Drop im Test funktionsfähig | Ziehen einer Regel auf eine andere tauscht Prioritäten | **Bestanden.** `TestHandleSetPriority_ReordersRule` (Backend) prüft `SetPriority` end-to-end gegen echte Postgres-DB; Frontend-Test `RuleEditor.test.tsx` prüft die gleichwertige Tastatur-Aktion (Drag & Drop selbst ist im jsdom-Test nicht simulierbar, daher über die Tastatur-Alternative abgedeckt — beide Wege rufen denselben Endpunkt auf) |
| 2 | Testlauf einer Regel vor Aktivierung möglich | `/routing-rules/test` liefert Treffer nach Priorität, keine Seiteneffekte | **Bestanden.** `TestHandleTest_MatchesEnabledRuleByPriority`: zwei Regeln mit identischer Bedingung, unterschiedlicher Priorität — höhere gewinnt. `TestHandleTest_NoMatch`: kein Treffer wird korrekt gemeldet. Frontend-Test bestätigt Anzeige des Ergebnisses |
| 3 | Ungültige Bedingungseingabe wird abgewiesen | Unbekannter Operator/leeres Feld → 400 | **Bestanden.** `TestHandleCreate_RejectsInvalidCondition`, `TestHandleCreate_RejectsMissingField` — beide 400, keine Zeile in der DB |
## Weitere Testergebnisse
- **Backend:** `go build ./...` und `go vet ./...` für das gesamte `workflow`-Modul sauber.
10/10 Tests grün (`routingrule`: 4 unverändert bestehende + `routingruleapi`: 6 neue).
- **Frontend:** `npx vitest run` — 7/7 Tests grün (3× BFF-Route, 4× Komponente inkl. Anlage-Formular).
- **`npx tsc --noEmit`:** Fehler ausschließlich in `../shl/*` (vorbestehende
Symlink-Baseline, identisch zu APR-03/VOR-05 dokumentiert, keine Regression). Eigener
Code (`app/`, `lib/`) fehlerfrei.
- **`npx next build` (Turbopack, echter Produktionsbuild):** erfolgreich, 5 Routen kompiliert.
Bekannter Infra-Stolperstein vorab behoben: `node_modules/@nexarch/shl` war ein Symlink,
den Turbopacks Monorepo-Auflösung nicht findet — durch echte Kopie ersetzt.
- **Echter Browser-A11y-Test** (Chrome stable + pa11y, WCAG2AA) gegen den laufenden
Produktionsbuild (`next start`, Port 4713, Host 204): Login-Formular (`/`) und
Editor-Fehlerzustand (`/?user=alice`, Backend nicht erreichbar, `role="alert"`
sichtbar). Beide: **"No issues found!"**.
## Nicht Bestandteil dieser Kachel (bewusst nicht umgesetzt)
- Mehrere Bedingungen/Aktionen je Regel im Anlage-Formular (kleinste Lösung: eine
Bedingung, eine Aktion; Erweiterung auf mehrere ist eine spätere Kachel, nicht Teil der
drei Akzeptanzkriterien dieser hier — der Backend-Endpunkt selbst unterstützt bereits
beliebig viele über die API)
- Integration mit `routingengine` (RTE-02) selbst — folgt automatisch, sobald RTE-02
gemerged ist, da beide auf demselben `routingrule`-Datenmodell und derselben
Priorisierungslogik aufbauen
+57
View File
@@ -0,0 +1,57 @@
// RTE-03: additive Erweiterungen für den Regel-Editor (Priorisierung,
// vollständige Liste inkl. deaktivierter Regeln). Verändert keinen
// bestehenden Code aus RTE-01.
package routingrule
import (
"context"
"encoding/json"
"fmt"
)
// ListAll liefert alle Regeln (aktiviert und deaktiviert), sortiert nach
// Priorität absteigend — für die Editor-Oberfläche, die im Gegensatz zur
// Ausführung (RTE-02) auch deaktivierte Regeln zeigen und verwalten muss.
func (s *Store) ListAll(ctx context.Context) ([]Rule, error) {
rows, err := s.pool.Query(ctx, `
SELECT rr.id, rr.name, rr.priority, rr.enabled, rr.active_version, rv.conditions, rv.actions
FROM routing_rules rr
JOIN routing_rule_versions rv ON rv.rule_id = rr.id AND rv.version = rr.active_version
ORDER BY rr.priority DESC, rr.created_at ASC
`)
if err != nil {
return nil, fmt.Errorf("routingrule: regeln auflisten: %w", err)
}
defer rows.Close()
var out []Rule
for rows.Next() {
var r Rule
var condJSON, actJSON []byte
if err := rows.Scan(&r.ID, &r.Name, &r.Priority, &r.Enabled, &r.ActiveVersion, &condJSON, &actJSON); err != nil {
return nil, fmt.Errorf("routingrule: zeile lesen: %w", err)
}
if err := json.Unmarshal(condJSON, &r.Conditions); err != nil {
return nil, fmt.Errorf("routingrule: bedingungen dekodieren: %w", err)
}
if err := json.Unmarshal(actJSON, &r.Actions); err != nil {
return nil, fmt.Errorf("routingrule: aktionen dekodieren: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// SetPriority ändert nur die Priorität einer Regel (Drag&Drop-Umsortierung
// in der Oberfläche) ohne eine neue Version anzulegen — Priorität ist keine
// versionierte Regeleigenschaft, anders als Bedingungen/Aktionen.
func (s *Store) SetPriority(ctx context.Context, id string, priority int) error {
tag, err := s.pool.Exec(ctx, `UPDATE routing_rules SET priority = $1 WHERE id = $2`, priority, id)
if err != nil {
return fmt.Errorf("routingrule: prioritaet setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+227
View File
@@ -0,0 +1,227 @@
// Package routingruleapi ist die HTTP-Bindung fuer den Routing-Regel-Editor
// (RTE-03). Nutzeridentitaet uebergangsweise ueber X-User-Id, gleiches
// Muster wie approvalapi (APR-01) vor der Core-JWT-Anbindung.
package routingruleapi
import (
"encoding/json"
"errors"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule"
)
var allowedOperators = map[string]bool{"equals": true, "contains": true, "in": true}
var allowedActionTypes = map[string]bool{"route_to_process": true, "route_to_person": true}
type Server struct {
store *routingrule.Store
}
func NewServer(store *routingrule.Store) *Server {
return &Server{store: store}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /routing-rules", s.requireUser(s.handleList))
mux.HandleFunc("POST /routing-rules", s.requireUser(s.handleCreate))
mux.HandleFunc("PUT /routing-rules/{id}", s.requireUser(s.handleUpdate))
mux.HandleFunc("PUT /routing-rules/{id}/priority", s.requireUser(s.handleSetPriority))
mux.HandleFunc("PUT /routing-rules/{id}/enabled", s.requireUser(s.handleSetEnabled))
mux.HandleFunc("POST /routing-rules/test", s.requireUser(s.handleTest))
}
func (s *Server) requireUser(next func(w http.ResponseWriter, r *http.Request, userID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
if userID == "" {
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
return
}
next(w, r, userID)
}
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request, _ string) {
rules, err := s.store.ListAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "regeln konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, rules)
}
type ruleInput struct {
Name string `json:"name"`
Priority int `json:"priority"`
Conditions []routingrule.Condition `json:"conditions"`
Actions []routingrule.Action `json:"actions"`
}
// validate lehnt strukturell ungueltige Bedingungen/Aktionen ab, bevor sie
// gespeichert werden (Pruefung 3: ungueltige Bedingungseingabe wird
// abgewiesen). Der Store selbst validiert das nicht (RTE-01-Scope), daher
// hier auf API-Ebene statt den bestehenden Store-Code zu veraendern.
func validate(in ruleInput) error {
if in.Name == "" {
return errors.New("name ist pflicht")
}
for _, c := range in.Conditions {
if c.Field == "" {
return errors.New("bedingung: feld ist pflicht")
}
if !allowedOperators[c.Operator] {
return errors.New("bedingung: unbekannter operator " + c.Operator)
}
if c.Value == nil {
return errors.New("bedingung: wert ist pflicht")
}
}
for _, a := range in.Actions {
if !allowedActionTypes[a.Type] {
return errors.New("aktion: unbekannter typ " + a.Type)
}
if a.Target == "" {
return errors.New("aktion: ziel ist pflicht")
}
}
return nil
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request, _ string) {
var in ruleInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := validate(in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule, err := s.store.Create(r.Context(), in.Name, in.Priority, in.Conditions, in.Actions)
if err != nil {
writeError(w, http.StatusInternalServerError, "regel konnte nicht angelegt werden")
return
}
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, _ string) {
id := r.PathValue("id")
var in ruleInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := validate(in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule, err := s.store.Update(r.Context(), id, in.Conditions, in.Actions)
if err != nil {
if errors.Is(err, routingrule.ErrNotFound) {
writeError(w, http.StatusNotFound, "regel nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "regel konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, rule)
}
type priorityInput struct {
Priority int `json:"priority"`
}
func (s *Server) handleSetPriority(w http.ResponseWriter, r *http.Request, _ string) {
id := r.PathValue("id")
var in priorityInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := s.store.SetPriority(r.Context(), id, in.Priority); err != nil {
if errors.Is(err, routingrule.ErrNotFound) {
writeError(w, http.StatusNotFound, "regel nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "prioritaet konnte nicht gesetzt werden")
return
}
w.WriteHeader(http.StatusNoContent)
}
type enabledInput struct {
Enabled bool `json:"enabled"`
}
func (s *Server) handleSetEnabled(w http.ResponseWriter, r *http.Request, _ string) {
id := r.PathValue("id")
var in enabledInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if err := s.store.SetEnabled(r.Context(), id, in.Enabled); err != nil {
if errors.Is(err, routingrule.ErrNotFound) {
writeError(w, http.StatusNotFound, "regel nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "status konnte nicht gesetzt werden")
return
}
w.WriteHeader(http.StatusNoContent)
}
type testInput struct {
Object map[string]any `json:"object"`
}
type testResult struct {
Matched bool `json:"matched"`
RuleID string `json:"rule_id,omitempty"`
RuleName string `json:"rule_name,omitempty"`
Actions []routingrule.Action `json:"actions,omitempty"`
}
// handleTest fuehrt eine Regel probeweise gegen ein Beispielobjekt aus
// (Pruefung 2: Testlauf vor Aktivierung), ohne eine echte Ausfuehrung
// (RTE-02, routing_decisions) anzustossen — reiner Lesevorgang.
func (s *Server) handleTest(w http.ResponseWriter, r *http.Request, _ string) {
var in testInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
if in.Object == nil {
writeError(w, http.StatusBadRequest, "object ist pflicht")
return
}
rules, err := s.store.ListAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "regeln konnten nicht geladen werden")
return
}
// ListAll sortiert bereits nach Prioritaet absteigend; erste passende
// (aktivierte) Regel gewinnt (RTE-02-Prinzip), hier nur simuliert.
for _, rule := range rules {
if !rule.Enabled {
continue
}
if rule.Matches(in.Object) {
writeJSON(w, http.StatusOK, testResult{Matched: true, RuleID: rule.ID, RuleName: rule.Name, Actions: rule.Actions})
return
}
}
writeJSON(w, http.StatusOK, testResult{Matched: false})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
@@ -0,0 +1,184 @@
package routingruleapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/testdb"
)
func findModuleRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod nicht gefunden")
}
dir = parent
}
}
func newTestServer(t *testing.T) *Server {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
return NewServer(routingrule.NewStore(pool))
}
func doJSON(t *testing.T, s *Server, method, path string, body any, userID string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("body kodieren: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
if userID != "" {
req.Header.Set("X-User-Id", userID)
}
mux := http.NewServeMux()
s.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleList_RequiresUser(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "GET", "/routing-rules", nil, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401, bekommen %d", rec.Code)
}
}
// AC1: Regeln sind priorisierbar (SetPriority ueber die API erreichbar).
func TestHandleSetPriority_ReordersRule(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "regel-a", Priority: 1,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "approval"}},
}, "u1")
if rec.Code != http.StatusCreated {
t.Fatalf("anlegen fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var created routingrule.Rule
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
rec = doJSON(t, s, "PUT", "/routing-rules/"+created.ID+"/priority", priorityInput{Priority: 99}, "u1")
if rec.Code != http.StatusNoContent {
t.Fatalf("prioritaet setzen fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, s, "GET", "/routing-rules", nil, "u1")
var rules []routingrule.Rule
if err := json.Unmarshal(rec.Body.Bytes(), &rules); err != nil {
t.Fatalf("liste dekodieren: %v", err)
}
if len(rules) == 0 || rules[0].ID != created.ID || rules[0].Priority != 99 {
t.Fatalf("regel nicht mit neuer prioritaet an erster stelle: %+v", rules)
}
}
// AC3 / Pruefung 2: Testlauf einer Regel vor Aktivierung, ohne echte
// Ausfuehrung anzustossen (keine routing_decisions-Zeile).
func TestHandleTest_MatchesEnabledRuleByPriority(t *testing.T) {
s := newTestServer(t)
doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "niedrig", Priority: 1,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "low"}},
}, "u1")
doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "hoch", Priority: 10,
Conditions: []routingrule.Condition{{Field: "object_type", Operator: "equals", Value: "invoice"}},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "high"}},
}, "u1")
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{Object: map[string]any{"object_type": "invoice"}}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if !result.Matched || result.RuleName != "hoch" {
t.Fatalf("erwartet match auf hoehere prioritaet, bekommen %+v", result)
}
}
func TestHandleTest_NoMatch(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules/test", testInput{Object: map[string]any{"object_type": "unbekannt"}}, "u1")
if rec.Code != http.StatusOK {
t.Fatalf("testlauf fehlgeschlagen: %d %s", rec.Code, rec.Body.String())
}
var result testResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if result.Matched {
t.Fatalf("erwartet kein match, bekommen %+v", result)
}
}
// Pruefung 3: ungueltige Bedingungseingabe wird abgewiesen.
func TestHandleCreate_RejectsInvalidCondition(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "ungueltig",
Priority: 1,
Conditions: []routingrule.Condition{
{Field: "object_type", Operator: "nicht_unterstuetzt", Value: "invoice"},
},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "x"}},
}, "u1")
if rec.Code != http.StatusBadRequest {
t.Fatalf("erwartet 400 bei ungueltigem operator, bekommen %d", rec.Code)
}
}
func TestHandleCreate_RejectsMissingField(t *testing.T) {
s := newTestServer(t)
rec := doJSON(t, s, "POST", "/routing-rules", ruleInput{
Name: "ungueltig-2",
Priority: 1,
Conditions: []routingrule.Condition{
{Field: "", Operator: "equals", Value: "invoice"},
},
Actions: []routingrule.Action{{Type: "route_to_process", Target: "x"}},
}, "u1")
if rec.Code != http.StatusBadRequest {
t.Fatalf("erwartet 400 bei fehlendem feld, bekommen %d", rec.Code)
}
}