Merge RTE-03 into main
This commit is contained in:
commit
567e90500a
21 files changed
+4703
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.next
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3319
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user