TSK-04: aufgaben-oberflaeche
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dac7fff504
commit
ee6376162d
30 files changed
+5263
-7
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.next
|
||||
@@ -0,0 +1 @@
|
||||
install-links=true
|
||||
@@ -0,0 +1,84 @@
|
||||
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 { TaskKanban } from "./TaskKanban";
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{ui}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function listResponse() {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ ID: "t1", Title: "Rechnung pruefen", AssigneeType: "person", AssigneeID: "alice", Priority: "high", Status: "open", DueDate: null, References: null },
|
||||
]),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskKanban", () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// AK4: Spalten nach Status Offen/In Bearbeitung/Erledigt; Verschieben
|
||||
// (hier ueber die gleichwertige Tastaturaktion, siehe Komponenten-
|
||||
// kommentar) schreibt den neuen Status ueber den /status-Endpunkt
|
||||
// zurueck, der seinerseits task.Store.SetStatus (TSK-01) aufruft.
|
||||
it("zeigt Aufgaben in ihrer Statusspalte und verschiebt per Aktionsknopf nach In Bearbeitung", async () => {
|
||||
const statusCalls: { url: string; body: string }[] = [];
|
||||
global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = url.toString();
|
||||
if (u.includes("/status")) {
|
||||
statusCalls.push({ url: u, body: init?.body as string });
|
||||
return new Response(JSON.stringify({ ID: "t1", Status: "in_progress" }), { status: 200 });
|
||||
}
|
||||
return listResponse();
|
||||
}) as typeof fetch;
|
||||
|
||||
renderWithProviders(<TaskKanban userId="alice" />);
|
||||
|
||||
await screen.findByText(/Rechnung pruefen/);
|
||||
expect(screen.getByRole("group", { name: /Spalte Offen/i })).toBeInTheDocument();
|
||||
|
||||
const moveButton = screen.getByRole("button", { name: /nach In Bearbeitung verschieben/i });
|
||||
fireEvent.click(moveButton);
|
||||
|
||||
await waitFor(() => expect(statusCalls.length).toBe(1));
|
||||
expect(statusCalls[0].url).toContain("/api/tasks/t1/status");
|
||||
expect(JSON.parse(statusCalls[0].body)).toEqual({ status: "in_progress" });
|
||||
});
|
||||
|
||||
it("verschiebt eine Aufgabe per Drag & Drop in die Spalte Erledigt", async () => {
|
||||
const statusCalls: string[] = [];
|
||||
global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = url.toString();
|
||||
if (u.includes("/status")) {
|
||||
statusCalls.push(init?.body as string);
|
||||
return new Response(JSON.stringify({ ID: "t1", Status: "done" }), { status: 200 });
|
||||
}
|
||||
return listResponse();
|
||||
}) as typeof fetch;
|
||||
|
||||
renderWithProviders(<TaskKanban userId="alice" />);
|
||||
await screen.findByText(/Rechnung pruefen/);
|
||||
|
||||
const card = screen.getByText(/Rechnung pruefen/).closest("li")!;
|
||||
const doneColumn = screen.getByRole("group", { name: /Spalte Erledigt/i });
|
||||
|
||||
const dataTransfer = { getData: () => "t1", setData: () => {} };
|
||||
fireEvent.dragStart(card, { dataTransfer });
|
||||
fireEvent.dragOver(doneColumn, { dataTransfer });
|
||||
fireEvent.drop(doneColumn, { dataTransfer });
|
||||
|
||||
await waitFor(() => expect(statusCalls.length).toBe(1));
|
||||
expect(JSON.parse(statusCalls[0])).toEqual({ status: "done" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useToast } from "@nexarch/shl";
|
||||
import { listTasks, setTaskStatus, ApiError, type Task, type Status } from "../lib/api";
|
||||
|
||||
const COLUMNS: { key: Status; label: string }[] = [
|
||||
{ key: "open", label: "Offen" },
|
||||
{ key: "in_progress", label: "In Bearbeitung" },
|
||||
{ key: "done", label: "Erledigt" },
|
||||
];
|
||||
|
||||
// AK4: Kanban-Ansicht mit Spalten nach Aufgabenstatus. Drag & Drop
|
||||
// schreibt den neuen Status ausschliesslich ueber
|
||||
// task.Store.SetStatus/taskapi (TSK-01) zurueck -- siehe lib/api.ts
|
||||
// setTaskStatus. Zusaetzlich gleichwertige Tastaturbedienung ueber die
|
||||
// "Nach ... verschieben"-Aktionen (Drag & Drop allein ist nicht
|
||||
// tastaturbedienbar, selbes Muster wie web/workflow-cases/ReminderKanban,
|
||||
// UX-14-Barrierefreiheitsanspruch dieses Projekts).
|
||||
export function TaskKanban({ userId }: { userId: string }) {
|
||||
const [tasks, setTasks] = useState<Task[] | 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 listTasks(userId);
|
||||
setTasks(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Aufgaben konnten nicht geladen werden.");
|
||||
setTasks([]);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
async function move(id: string, target: Status) {
|
||||
try {
|
||||
await setTaskStatus(userId, id, target);
|
||||
await reload();
|
||||
if (target === "done") {
|
||||
toast.push("Aufgabe erledigt.", "success");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.push(err instanceof ApiError ? err.message : "Status konnte nicht geaendert werden.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p role="alert">{error}</p>;
|
||||
}
|
||||
if (tasks === null) {
|
||||
return <p>Lade Aufgaben …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
{COLUMNS.map((col) => {
|
||||
const columnTasks = tasks.filter((t) => t.Status === col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
role="group"
|
||||
aria-label={`Spalte ${col.label}`}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData("text/plain") || draggingId;
|
||||
if (id) {
|
||||
void move(id, col.key);
|
||||
}
|
||||
setDraggingId(null);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
border: "1px solid var(--shl-color-border, #d0d5dd)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
minHeight: 160,
|
||||
background: "var(--shl-color-surface, #f9fafb)",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>
|
||||
{col.label} ({columnTasks.length})
|
||||
</h3>
|
||||
{columnTasks.length === 0 ? (
|
||||
<p style={{ color: "var(--shl-color-text-secondary, #475467)" }}>Keine Aufgaben.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{columnTasks.map((t) => (
|
||||
<li
|
||||
key={t.ID}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", t.ID);
|
||||
setDraggingId(t.ID);
|
||||
}}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
style={{
|
||||
border: "1px solid var(--shl-color-border, #d0d5dd)",
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0 0 4px", fontWeight: 600 }}>{t.Title}</p>
|
||||
<p style={{ margin: "0 0 8px", color: "var(--shl-color-text-secondary, #475467)" }}>
|
||||
{t.AssigneeID} · {t.Priority}
|
||||
{t.DueDate ? ` · ${new Date(t.DueDate).toLocaleDateString("de-DE")}` : ""}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{COLUMNS.filter((c) => c.key !== col.key).map((target) => (
|
||||
<button
|
||||
key={target.key}
|
||||
type="button"
|
||||
onClick={() => void move(t.ID, target.key)}
|
||||
aria-label={`${t.Title} nach ${target.label} verschieben`}
|
||||
>
|
||||
→ {target.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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 { TaskList } from "./TaskList";
|
||||
import type { Task } from "../lib/api";
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{ui}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<Task>): Task {
|
||||
return {
|
||||
ID: Math.random().toString(36).slice(2),
|
||||
Title: "Aufgabe",
|
||||
AssigneeType: "person",
|
||||
AssigneeID: "alice",
|
||||
Priority: "normal",
|
||||
Status: "open",
|
||||
DueDate: null,
|
||||
References: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskList", () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// AK1: Ueberfaellige Aufgaben sind klar gekennzeichnet.
|
||||
it("kennzeichnet eine ueberfaellige Aufgabe im Text, nicht nur farblich", async () => {
|
||||
const overdueDue = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
|
||||
global.fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify([makeTask({ Title: "Alte Aufgabe", DueDate: overdueDue })]), { status: 200 })
|
||||
) as typeof fetch;
|
||||
|
||||
renderWithProviders(<TaskList userId="alice" />);
|
||||
|
||||
await screen.findByText(/Alte Aufgabe/);
|
||||
expect(screen.getByText("Ueberfaellig", { selector: "strong" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AK2: Liste ist nach Faelligkeit und Prioritaet filterbar.
|
||||
it("filtert die Liste nach Prioritaet", async () => {
|
||||
global.fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
makeTask({ Title: "Hohe Prioritaet", Priority: "high" }),
|
||||
makeTask({ Title: "Niedrige Prioritaet", Priority: "low" }),
|
||||
]),
|
||||
{ status: 200 }
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
renderWithProviders(<TaskList userId="alice" />);
|
||||
await screen.findByText(/Hohe Prioritaet/);
|
||||
expect(screen.getByText(/Niedrige Prioritaet/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Prioritaet"), { target: { value: "high" } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/Niedrige Prioritaet/)).not.toBeInTheDocument());
|
||||
expect(screen.getByText(/Hohe Prioritaet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filtert die Liste nach Ueberfaellig", async () => {
|
||||
const overdueDue = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const futureDue = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString();
|
||||
global.fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
makeTask({ Title: "Ueberfaellige Aufgabe", DueDate: overdueDue }),
|
||||
makeTask({ Title: "Kuenftige Aufgabe", DueDate: futureDue }),
|
||||
]),
|
||||
{ status: 200 }
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
renderWithProviders(<TaskList userId="alice" />);
|
||||
await screen.findByText(/Ueberfaellige Aufgabe/);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Faelligkeit"), { target: { value: "overdue" } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/Kuenftige Aufgabe/)).not.toBeInTheDocument());
|
||||
expect(screen.getByText(/Ueberfaellige Aufgabe/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AK3 / Pruefung 3: Ansicht bleibt bei 200 Aufgaben nutzbar -- misst die
|
||||
// tatsaechliche Renderzeit von 200 Zeilen inklusive Filterlogik.
|
||||
it("rendert 200 Aufgaben in nutzbarer Zeit", async () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) =>
|
||||
makeTask({ Title: `Aufgabe ${i}`, Priority: i % 3 === 0 ? "high" : "normal" })
|
||||
);
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify(many), { status: 200 })) as typeof fetch;
|
||||
|
||||
const start = performance.now();
|
||||
renderWithProviders(<TaskList userId="alice" />);
|
||||
await screen.findByText(/Aufgabe 199/);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`TaskList Render mit 200 Aufgaben: ${elapsed.toFixed(1)}ms`);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
expect(screen.getByText(/200 von 200 Aufgaben/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { SelectField, Table, type TableColumn } from "@nexarch/shl";
|
||||
import { listTasks, ApiError, type Task } from "../lib/api";
|
||||
import { isOverdue, filterTasks, sortByDueDate, type DueFilter, type PriorityFilter } from "../lib/filters";
|
||||
|
||||
const PRIORITY_LABEL: Record<Task["Priority"], string> = {
|
||||
low: "Niedrig",
|
||||
normal: "Normal",
|
||||
high: "Hoch",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<Task["Status"], string> = {
|
||||
open: "Offen",
|
||||
in_progress: "In Bearbeitung",
|
||||
done: "Erledigt",
|
||||
};
|
||||
|
||||
// AK1: Liste ist nach Faelligkeit und Prioritaet filterbar.
|
||||
// AK2: Ueberfaellige Aufgaben sind klar gekennzeichnet (Text-Badge, nicht
|
||||
// nur Farbe -- WCAG, Farbe allein ist keine verlaessliche Kennzeichnung).
|
||||
// AK3: bleibt bei 200 Aufgaben nutzbar -- 200 Zeilen sind fuer eine
|
||||
// einfache Tabelle kein Performance-Problem, siehe lib/filters.test.ts
|
||||
// fuer den gemessenen Wert.
|
||||
export function TaskList({ userId }: { userId: string }) {
|
||||
const [tasks, setTasks] = useState<Task[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [priority, setPriority] = useState<PriorityFilter>("all");
|
||||
const [due, setDue] = useState<DueFilter>("all");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const result = await listTasks(userId);
|
||||
setTasks(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Aufgaben konnten nicht geladen werden.");
|
||||
setTasks([]);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const now = useMemo(() => new Date(), [tasks]);
|
||||
const visible = useMemo(() => {
|
||||
if (!tasks) return [];
|
||||
return sortByDueDate(filterTasks(tasks, priority, due, now));
|
||||
}, [tasks, priority, due, now]);
|
||||
|
||||
if (error) {
|
||||
return <p role="alert">{error}</p>;
|
||||
}
|
||||
if (tasks === null) {
|
||||
return <p>Lade Aufgaben …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
<SelectField
|
||||
label="Prioritaet"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as PriorityFilter)}
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Hoch</option>
|
||||
</SelectField>
|
||||
<SelectField label="Faelligkeit" value={due} onChange={(e) => setDue(e.target.value as DueFilter)}>
|
||||
<option value="all">Alle</option>
|
||||
<option value="overdue">Ueberfaellig</option>
|
||||
<option value="next7">Naechste 7 Tage</option>
|
||||
<option value="none">Ohne Faelligkeit</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
|
||||
<p aria-live="polite">
|
||||
{visible.length} von {tasks.length} Aufgaben
|
||||
</p>
|
||||
|
||||
<Table<Task>
|
||||
caption="Aufgaben"
|
||||
rows={visible}
|
||||
rowKey={(t) => t.ID}
|
||||
columns={columns(now)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function columns(now: Date): TableColumn<Task>[] {
|
||||
return [
|
||||
{ key: "title", header: "Titel", render: (t) => t.Title },
|
||||
{ key: "assignee", header: "Zustaendig", render: (t) => t.AssigneeID },
|
||||
{ key: "priority", header: "Prioritaet", render: (t) => PRIORITY_LABEL[t.Priority] },
|
||||
{
|
||||
key: "due",
|
||||
header: "Faelligkeit",
|
||||
render: (t) => (
|
||||
<>
|
||||
{t.DueDate ? new Date(t.DueDate).toLocaleDateString("de-DE") : "–"}
|
||||
{isOverdue(t, now) ? (
|
||||
<strong style={{ color: "var(--shl-color-danger, #b42318)", marginLeft: 8 }}>Ueberfaellig</strong>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "Status", render: (t) => STATUS_LABEL[t.Status] },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
describe("POST /api/tasks/[id]/status", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalEnv = process.env.TASK_BOARD_API_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TASK_BOARD_API_URL = "http://backend.local";
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
process.env.TASK_BOARD_API_URL = originalEnv;
|
||||
});
|
||||
|
||||
it("leitet den neuen Status an das Backend weiter (AK4: Schreibweg der Kanban-Ansicht)", async () => {
|
||||
let capturedURL = "";
|
||||
let capturedBody = "";
|
||||
global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
capturedURL = url.toString();
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(JSON.stringify({ ID: "t1", Status: "in_progress" }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const req = new NextRequest("http://localhost/api/tasks/t1/status?userId=alice", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status: "in_progress" }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "t1" }) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedURL).toBe("http://backend.local/tasks/t1/status");
|
||||
expect(JSON.parse(capturedBody)).toEqual({ status: "in_progress" });
|
||||
});
|
||||
|
||||
it("liefert 400 ohne status", async () => {
|
||||
const req = new NextRequest("http://localhost/api/tasks/t1/status?userId=alice", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "t1" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("liefert 400 ohne userId", async () => {
|
||||
const req = new NextRequest("http://localhost/api/tasks/t1/status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status: "done" }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "t1" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
function backendURL(): string {
|
||||
const base = process.env.TASK_BOARD_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("TASK_BOARD_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await 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?.status) {
|
||||
return NextResponse.json({ error: "'status' ist erforderlich" }, { status: 400 });
|
||||
}
|
||||
|
||||
let backendResponse: Response;
|
||||
try {
|
||||
backendResponse = await fetch(`${backendURL()}/tasks/${encodeURIComponent(id)}/status`, {
|
||||
method: "POST",
|
||||
headers: { "X-User-Id": userId, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: payload.status }),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: `Workflow-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
|
||||
}
|
||||
const body = await backendResponse.json().catch(() => null);
|
||||
return NextResponse.json(body ?? { error: "Status konnte nicht geaendert werden" }, { status: backendResponse.status });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("GET /api/tasks", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalEnv = process.env.TASK_BOARD_API_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TASK_BOARD_API_URL = "http://backend-mandant-a.local";
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
process.env.TASK_BOARD_API_URL = originalEnv;
|
||||
});
|
||||
|
||||
it("liefert 400 ohne userId", async () => {
|
||||
const req = new NextRequest("http://localhost/api/tasks");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("ruft ausschliesslich das konfigurierte Backend auf und reicht die Antwort durch", async () => {
|
||||
let capturedURL = "";
|
||||
let capturedHeaders: Headers | undefined;
|
||||
global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
capturedURL = url.toString();
|
||||
capturedHeaders = new Headers(init?.headers);
|
||||
return new Response(JSON.stringify([{ ID: "1", Title: "x" }]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const req = new NextRequest("http://localhost/api/tasks?userId=alice");
|
||||
const res = await GET(req);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedURL).toBe("http://backend-mandant-a.local/tasks");
|
||||
expect(capturedHeaders?.get("X-User-Id")).toBe("alice");
|
||||
});
|
||||
|
||||
// AK5: ein Client-seitiger Mandanten-Parameter darf die Zielinstanz
|
||||
// nicht umlenken -- die Route liest nur 'userId' aus der Anfrage, der
|
||||
// Mandant ist durch TASK_BOARD_API_URL beim Deployment fest gebunden.
|
||||
it("ignoriert einen client-seitigen Mandanten-Parameter, Ziel bleibt das konfigurierte Backend", async () => {
|
||||
let capturedURL = "";
|
||||
global.fetch = vi.fn(async (url: string | URL) => {
|
||||
capturedURL = url.toString();
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const req = new NextRequest("http://localhost/api/tasks?userId=alice&tenant=mandant-b&mandant=mandant-b");
|
||||
await GET(req);
|
||||
|
||||
expect(capturedURL).toBe("http://backend-mandant-a.local/tasks");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
function backendURL(): string {
|
||||
const base = process.env.TASK_BOARD_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("TASK_BOARD_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
// GET liest ausschliesslich 'userId' aus der Anfrage und leitet ohne
|
||||
// weitere Parameter an das fest konfigurierte Backend weiter (AK5: kein
|
||||
// Mandanten-Parameter, den ein Client setzen koennte -- welcher Mandant
|
||||
// angesprochen wird, legt TASK_BOARD_API_URL beim Deployment fest, nicht
|
||||
// die Anfrage).
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = request.nextUrl.searchParams.get("userId");
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
|
||||
}
|
||||
|
||||
let backendResponse: Response;
|
||||
try {
|
||||
backendResponse = await fetch(`${backendURL()}/tasks`, {
|
||||
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: "Aufgaben konnten nicht geladen werden" }, { 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 Aufgaben",
|
||||
};
|
||||
|
||||
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,55 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { TextField } from "@nexarch/shl";
|
||||
import { TaskList } from "./TaskList";
|
||||
import { TaskKanban } from "./TaskKanban";
|
||||
|
||||
type Tab = "list" | "kanban";
|
||||
|
||||
function TaskBoardPage() {
|
||||
const params = useSearchParams();
|
||||
const userId = params.get("user") ?? "";
|
||||
const [tab, setTab] = useState<Tab>("list");
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 1100, margin: "0 auto", padding: 24 }}>
|
||||
<h1>Aufgaben</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="Uebergangsweise ohne zentrale Session (VOR-01/APR-01)" />
|
||||
<button type="submit">Uebernehmen</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div role="tablist" aria-label="Ansicht" style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<button type="button" role="tab" aria-selected={tab === "list"} onClick={() => setTab("list")}>
|
||||
Liste
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === "kanban"} onClick={() => setTab("kanban")}>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
{tab === "list" ? <TaskList userId={userId} /> : <TaskKanban userId={userId} />}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<p>Lade …</p>}>
|
||||
<TaskBoardPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ApiError, listTasks, setTaskStatus } from "./api";
|
||||
|
||||
describe("listTasks/setTaskStatus", () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("listTasks liefert die dekodierte Liste", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify([{ ID: "1", Title: "x" }]), { status: 200 })) as typeof fetch;
|
||||
const tasks = await listTasks("alice");
|
||||
expect(tasks).toEqual([{ ID: "1", Title: "x" }]);
|
||||
});
|
||||
|
||||
it("listTasks wirft ApiError bei Fehlerstatus", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({ error: "kaputt" }), { status: 500 })) as typeof fetch;
|
||||
await expect(listTasks("alice")).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it("setTaskStatus sendet status im Koerper und liefert die aktualisierte Aufgabe", async () => {
|
||||
let capturedBody = "";
|
||||
global.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(JSON.stringify({ ID: "1", Status: "done" }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const task = await setTaskStatus("alice", "1", "done");
|
||||
expect(JSON.parse(capturedBody)).toEqual({ status: "done" });
|
||||
expect(task.Status).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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/lib/api.ts.
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
ModuleName: string;
|
||||
ObjectType: string;
|
||||
ObjectID: string;
|
||||
}
|
||||
|
||||
export type Priority = "low" | "normal" | "high";
|
||||
export type Status = "open" | "in_progress" | "done";
|
||||
|
||||
export interface Task {
|
||||
ID: string;
|
||||
Title: string;
|
||||
AssigneeType: "person" | "group";
|
||||
AssigneeID: string;
|
||||
Priority: Priority;
|
||||
Status: Status;
|
||||
DueDate: string | null;
|
||||
References: Reference[] | null;
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response, fallback: string): Promise<T> {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(body?.error ?? `${fallback} (${res.status})`, res.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function listTasks(userId: string): Promise<Task[]> {
|
||||
const res = await fetch(`/api/tasks?userId=${encodeURIComponent(userId)}`, { cache: "no-store" });
|
||||
return parse<Task[]>(res, "Aufgaben konnten nicht geladen werden");
|
||||
}
|
||||
|
||||
export async function setTaskStatus(userId: string, id: string, status: Status): Promise<Task> {
|
||||
const res = await fetch(`/api/tasks/${encodeURIComponent(id)}/status?userId=${encodeURIComponent(userId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
return parse<Task>(res, "Status konnte nicht geaendert werden");
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isOverdue, filterTasks, sortByDueDate } from "./filters";
|
||||
import type { Task } from "./api";
|
||||
|
||||
function makeTask(overrides: Partial<Task>): Task {
|
||||
return {
|
||||
ID: Math.random().toString(36).slice(2),
|
||||
Title: "Aufgabe",
|
||||
AssigneeType: "person",
|
||||
AssigneeID: "user-1",
|
||||
Priority: "normal",
|
||||
Status: "open",
|
||||
DueDate: null,
|
||||
References: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00Z");
|
||||
|
||||
describe("isOverdue (AK1: Ueberfaellig-Kennzeichnung)", () => {
|
||||
it("ist ueberfaellig, wenn Faelligkeit in der Vergangenheit liegt und offen ist", () => {
|
||||
const t = makeTask({ DueDate: "2026-10-10T00:00:00Z", Status: "open" });
|
||||
expect(isOverdue(t, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("ist nicht ueberfaellig ohne Faelligkeit", () => {
|
||||
const t = makeTask({ DueDate: null });
|
||||
expect(isOverdue(t, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("ist nicht ueberfaellig, wenn bereits erledigt, auch bei abgelaufener Faelligkeit", () => {
|
||||
const t = makeTask({ DueDate: "2026-10-10T00:00:00Z", Status: "done" });
|
||||
expect(isOverdue(t, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("ist nicht ueberfaellig bei Faelligkeit in der Zukunft", () => {
|
||||
const t = makeTask({ DueDate: "2026-10-20T00:00:00Z" });
|
||||
expect(isOverdue(t, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTasks (AK2: Filter nach Faelligkeit und Prioritaet)", () => {
|
||||
const tasks = [
|
||||
makeTask({ Priority: "high", DueDate: "2026-10-10T00:00:00Z" }), // ueberfaellig
|
||||
makeTask({ Priority: "low", DueDate: "2026-10-18T00:00:00Z" }), // naechste 7 Tage
|
||||
makeTask({ Priority: "normal", DueDate: null }), // ohne faelligkeit
|
||||
makeTask({ Priority: "high", DueDate: "2026-12-01T00:00:00Z" }), // weit in der zukunft
|
||||
];
|
||||
|
||||
it("filtert nach Prioritaet", () => {
|
||||
const result = filterTasks(tasks, "high", "all", NOW);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((t) => t.Priority === "high")).toBe(true);
|
||||
});
|
||||
|
||||
it("filtert ueberfaellige Aufgaben", () => {
|
||||
const result = filterTasks(tasks, "all", "overdue", NOW);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].DueDate).toBe("2026-10-10T00:00:00Z");
|
||||
});
|
||||
|
||||
it("filtert Aufgaben ohne Faelligkeit", () => {
|
||||
const result = filterTasks(tasks, "all", "none", NOW);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].DueDate).toBeNull();
|
||||
});
|
||||
|
||||
it("filtert Aufgaben der naechsten 7 Tage", () => {
|
||||
const result = filterTasks(tasks, "all", "next7", NOW);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].DueDate).toBe("2026-10-18T00:00:00Z");
|
||||
});
|
||||
|
||||
it("kombiniert Prioritaets- und Faelligkeitsfilter", () => {
|
||||
const result = filterTasks(tasks, "high", "overdue", NOW);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].Priority).toBe("high");
|
||||
expect(result[0].DueDate).toBe("2026-10-10T00:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByDueDate", () => {
|
||||
it("sortiert aufsteigend, undatierte Aufgaben zuletzt", () => {
|
||||
const tasks = [
|
||||
makeTask({ Title: "ohne", DueDate: null }),
|
||||
makeTask({ Title: "spaeter", DueDate: "2026-11-01T00:00:00Z" }),
|
||||
makeTask({ Title: "frueher", DueDate: "2026-10-17T00:00:00Z" }),
|
||||
];
|
||||
const sorted = sortByDueDate(tasks);
|
||||
expect(sorted.map((t) => t.Title)).toEqual(["frueher", "spaeter", "ohne"]);
|
||||
});
|
||||
});
|
||||
|
||||
// AK3 / Pruefung 3: Performance bei 200 Aufgaben gemessen -- reine
|
||||
// Filter-/Sortierlogik ueber einen synthetischen Datensatz, ohne DOM.
|
||||
describe("Performance bei 200 Aufgaben (Pruefung 3)", () => {
|
||||
it("filtert und sortiert 200 Aufgaben deutlich unterhalb einer spuerbaren Verzoegerung", () => {
|
||||
const priorities: Task["Priority"][] = ["low", "normal", "high"];
|
||||
const many: Task[] = Array.from({ length: 200 }, (_, i) =>
|
||||
makeTask({
|
||||
Title: `Aufgabe ${i}`,
|
||||
Priority: priorities[i % 3],
|
||||
DueDate: i % 5 === 0 ? null : new Date(NOW.getTime() + (i - 100) * 24 * 60 * 60 * 1000).toISOString(),
|
||||
Status: i % 7 === 0 ? "done" : "open",
|
||||
})
|
||||
);
|
||||
|
||||
const start = performance.now();
|
||||
const filtered = filterTasks(many, "high", "overdue", NOW);
|
||||
const sorted = sortByDueDate(filtered);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
// Nur ein belegter Messwert, kein hartes Budget auf einer CI-Maschine
|
||||
// -- aber 200 Aufgaben muessen weit unter 100ms bleiben, sonst waere
|
||||
// etwas grundlegend falsch an der Implementierung (AK3: Ansicht
|
||||
// bleibt bei 200 Aufgaben nutzbar).
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
expect(sorted.length).toBeGreaterThan(0);
|
||||
expect(sorted.every((t) => t.Priority === "high")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Task } from "./api";
|
||||
|
||||
export type DueFilter = "all" | "overdue" | "next7" | "none";
|
||||
export type PriorityFilter = "all" | Task["Priority"];
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
// isOverdue ist Grundlage von AK1 (Ueberfaellig-Kennzeichnung): eine
|
||||
// erledigte Aufgabe ist nie ueberfaellig, auch wenn ihre Faelligkeit in
|
||||
// der Vergangenheit liegt -- sie ist fertig, nicht versaeumt.
|
||||
export function isOverdue(task: Task, now: Date): boolean {
|
||||
if (task.Status === "done" || !task.DueDate) {
|
||||
return false;
|
||||
}
|
||||
return new Date(task.DueDate).getTime() < startOfDay(now).getTime();
|
||||
}
|
||||
|
||||
// filterTasks kombiniert Prioritaets- und Faelligkeitsfilter (AK2) als
|
||||
// reine, von React unabhaengige Funktion -- so bei 200 Aufgaben (AK3)
|
||||
// direkt mess- und testbar ohne DOM-Rendering.
|
||||
export function filterTasks(tasks: Task[], priority: PriorityFilter, due: DueFilter, now: Date): Task[] {
|
||||
const start = startOfDay(now).getTime();
|
||||
const next7End = start + 7 * 24 * 60 * 60 * 1000;
|
||||
return tasks.filter((t) => {
|
||||
if (priority !== "all" && t.Priority !== priority) {
|
||||
return false;
|
||||
}
|
||||
if (due === "overdue") {
|
||||
return isOverdue(t, now);
|
||||
}
|
||||
if (due === "none") {
|
||||
return !t.DueDate;
|
||||
}
|
||||
if (due === "next7") {
|
||||
if (!t.DueDate) return false;
|
||||
const due = new Date(t.DueDate).getTime();
|
||||
return due >= start && due < next7End;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function sortByDueDate(tasks: Task[]): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
if (!a.DueDate && !b.DueDate) return 0;
|
||||
if (!a.DueDate) return 1;
|
||||
if (!b.DueDate) return -1;
|
||||
return new Date(a.DueDate).getTime() - new Date(b.DueDate).getTime();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3305
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nexarch-task-board",
|
||||
"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);
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingruleapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsuggestclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/task"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/taskapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/tenantregistryclient"
|
||||
)
|
||||
|
||||
@@ -121,6 +122,7 @@ func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlu
|
||||
caseoverviewapi.NewServer(caseoverviewSvc).RegisterRoutes(mux)
|
||||
backchannelapi.NewServer(backchannelSvc).RegisterRoutes(mux)
|
||||
routingruleapi.NewServer(routingruleStore, buildRoutingSuggester()).RegisterRoutes(mux)
|
||||
taskapi.NewServer(taskStore).RegisterRoutes(mux)
|
||||
moduleadapter.NewServer(pool, serviceToken, tenantSlug).RegisterRoutes(mux)
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# TSK-04 – Aufgaben-Oberfläche: Prüfprotokoll
|
||||
|
||||
Datum: 2026-10-16. Host: Entwicklungshost 192.168.1.204, frischer Klon von `main`
|
||||
(Head `dac7fff`) in einem Wegwerf-Verzeichnis. Branch `feature/tsk-04-aufgaben-oberflaeche`.
|
||||
Kein Merge, kein Deploy.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
### Backend (`workflow/internal/task`, TSK-01 additiv erweitert)
|
||||
|
||||
- `Task` bekommt ein `Status`-Feld (`open`/`in_progress`/`done`), Migration
|
||||
`0016_task_status` (additive Spalte mit CHECK-Constraint, Default `open`).
|
||||
`Store.SetStatus` ist der **einzige Schreibweg** für den Kanban-Status —
|
||||
genau das, was Akzeptanzkriterium 4 verlangt ("Drag & Drop … schreibt
|
||||
neuen Status über TSK-01 zurück"). Bestehender Code (`Create`, `Get`,
|
||||
`ByAssignee`, `ByReference`, `SetDueDate`) bleibt kompatibel, nur additiv
|
||||
erweitert.
|
||||
- Neue Methode `Store.List` liefert alle Aufgaben des gebundenen
|
||||
Mandanten-Schemas, sortiert nach Fälligkeit (undatierte zuletzt) — die
|
||||
Grundlage für Liste und Kanban-Ansicht. Bewusst zwei Abfragen (Aufgaben,
|
||||
dann Referenzen gebündelt) statt eines N+1-Zugriffs wie bei
|
||||
`ByAssignee`/`ByReference`, weil das bei 200 Aufgaben (Prüfung 3) sonst
|
||||
separat messbar langsamer wäre.
|
||||
- Bewusst **kein** Abgleich mit `escalation.Store.Complete`/`completed_at`
|
||||
(TSK-03): das ist eine eigenständige Nachbar-Kachel mit eigener Spalte,
|
||||
ein Verknüpfen beider Zustände wäre eine Erweiterung über den
|
||||
TSK-04-Scope hinaus (nicht Teil der Akzeptanzkriterien).
|
||||
|
||||
### Neues Paket `workflow/internal/taskapi`
|
||||
|
||||
HTTP-Bindung nach demselben Muster wie `caseapi`/`approvalapi`
|
||||
(`X-User-Id`-Zwischenlösung, siehe deren eigene Kommentare):
|
||||
|
||||
- `GET /tasks` — alle Aufgaben des gebundenen Mandanten-Schemas.
|
||||
- `POST /tasks/{id}/status` — der Schreibweg für AK4, ruft ausschließlich
|
||||
`task.Store.SetStatus` auf.
|
||||
|
||||
Verdrahtet in `workflow/cmd/workflow-app/main.go` (`buildMux`), wie alle
|
||||
anderen Workflow-APIs.
|
||||
|
||||
### Architektur-Entscheidung zu AK5 (Mandanten-Trennung)
|
||||
|
||||
Dieses Modul hat **keine Mandanten-Spalte** in `tasks` — die Trennung ist
|
||||
im gesamten Workflow-Modul physisch: ein `workflow-app`-Prozess ist über
|
||||
`NEXARCH_WORKFLOW_APP_TENANT_DSN`/`_TENANT_SLUG` an genau einen
|
||||
Mandanten/eine Datenbank gebunden (siehe `nexarch-state.json`: "warum
|
||||
Tenancy keine RLS hat"). `taskapi.Server` erbt das: ein `Server` hält
|
||||
genau einen `task.Store`/Pool, es gibt keinen Mandanten-Parameter in der
|
||||
API. Die Next.js-Oberfläche folgt demselben Muster wie
|
||||
`web/workflow-cases`: ihre Backend-for-Frontend-Route liest nur `userId`
|
||||
aus der Anfrage und ruft ausschließlich die über `TASK_BOARD_API_URL`
|
||||
fest konfigurierte Backend-Instanz auf — kein Client-Parameter kann das
|
||||
Ziel umlenken. Serverseitige Filterung ist damit strukturell, nicht ein
|
||||
Filter, der sich umgehen ließe.
|
||||
|
||||
### Frontend `web/task-board` (neue App, Next.js 16/React 19/TypeScript, `@nexarch/shl`)
|
||||
|
||||
Nach dem Muster von `web/workflow-cases` (`.npmrc` mit `install-links=true`,
|
||||
`tsconfig.json` mit `jsx: react-jsx` + `.next/dev/types`-Include, wie seit
|
||||
OPS-11/QA-15 für alle Apps etabliert):
|
||||
|
||||
- `app/page.tsx`: Tab-Umschaltung Liste/Kanban, `Nutzerkennung`-Gate wie
|
||||
bei `workflow-cases`/`caseoverview` (keine zentrale Session, siehe
|
||||
VOR-01/APR-01-Kommentare).
|
||||
- `app/TaskList.tsx`: Liste über `@nexarch/shl`s `Table`-Komponente,
|
||||
Filter nach Priorität und Fälligkeit (Alle/Überfällig/Nächste 7
|
||||
Tage/Ohne Fälligkeit), Überfällig-Kennzeichnung als Text-Badge (nicht
|
||||
nur Farbe — WCAG).
|
||||
- `app/TaskKanban.tsx`: drei Spalten Offen/In Bearbeitung/Erledigt, Drag &
|
||||
Drop **und** gleichwertige Tastaturaktion ("Nach … verschieben"-Buttons,
|
||||
gleiches Barrierefreiheits-Muster wie `ReminderKanban` aus
|
||||
`workflow-cases`, UX-14-Anspruch dieses Projekts). Beide Wege rufen
|
||||
`setTaskStatus` → `POST /api/tasks/{id}/status` → `taskapi` →
|
||||
`task.Store.SetStatus`.
|
||||
- `app/api/tasks/route.ts`, `app/api/tasks/[id]/status/route.ts`:
|
||||
Backend-for-Frontend-Routen, identisches Muster wie
|
||||
`workflow-cases/app/api/...` (kein direkter Client-Zugriff auf die
|
||||
Workflow-API).
|
||||
- `lib/filters.ts`: reine Filter-/Sortierfunktionen (`isOverdue`,
|
||||
`filterTasks`, `sortByDueDate`), unabhängig vom DOM testbar.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Überfällige Aufgaben sind klar gekennzeichnet | **Erfüllt.** `isOverdue` (Status ≠ done, Fälligkeit < heute) + Text-Badge "Ueberfaellig" in der Tabelle. Test: `lib/filters.test.ts` (4 Fälle: überfällig, ohne Fälligkeit, erledigt+abgelaufen, Zukunft), `TaskList.test.tsx` ("kennzeichnet eine ueberfaellige Aufgabe im Text"). |
|
||||
| 2 | Liste ist nach Fälligkeit und Priorität filterbar | **Erfüllt.** `filterTasks` kombiniert beide Filter. Test: `lib/filters.test.ts` (Priorität, überfällig, nächste 7 Tage, ohne Fälligkeit, Kombination), `TaskList.test.tsx` (Filterwechsel über die Select-Felder). |
|
||||
| 3 | Ansicht bleibt bei 200 Aufgaben nutzbar | **Erfüllt**, siehe Performance-Messung unten. |
|
||||
| 4 | Kanban-Ansicht zeigt Spalten nach Status, Drag & Drop schreibt neuen Status über TSK-01 zurück | **Erfüllt.** Go: `taskapi.TestHandleSetStatus_UpdatesStatus` (HTTP → `task.Store.SetStatus` → DB-Lesebestätigung). Frontend: `TaskKanban.test.tsx` testet sowohl den Tastaturweg (Button-Klick) als auch echtes Drag & Drop (`fireEvent.dragStart/dragOver/drop`). **Live-E2E** unten bestätigt den kompletten Weg Browser-Aktion → Next.js-BFF → `workflow-app` → Postgres. |
|
||||
| 5 | Kanban-Ansicht zeigt ausschließlich Aufgaben des aktuellen Mandanten, serverseitig gefiltert | **Erfüllt**, siehe eigener Abschnitt unten (Go-Test + BFF-Test + Live-E2E mit zwei echten Mandanten-Schemas). |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
### 1. Filterkombination gegen Testdatensatz
|
||||
|
||||
`lib/filters.test.ts`, Testfall "kombiniert Prioritäts- und
|
||||
Fälligkeitsfilter": 4 synthetische Aufgaben (hoch/überfällig,
|
||||
niedrig/nächste 7 Tage, normal/ohne Fälligkeit, hoch/weit in der Zukunft),
|
||||
Filter `priority=high` + `due=overdue` liefert genau die eine erwartete
|
||||
Aufgabe. **Bestanden.**
|
||||
|
||||
### 2. Überfällig-Kennzeichnung bei Fristüberschreitung
|
||||
|
||||
`TaskList.test.tsx`: Aufgabe mit Fälligkeit vor 5 Tagen wird mit dem Text
|
||||
"Ueberfaellig" gerendert (nicht nur eine CSS-Klasse). **Bestanden.**
|
||||
|
||||
### 3. Performance bei 200 Aufgaben — gemessene Werte
|
||||
|
||||
- **Backend** (`workflow/internal/task`, `TestList_PerformanceWith200Tasks`):
|
||||
200 Aufgaben angelegt, `Store.List()` (2 SQL-Abfragen statt 200)
|
||||
gemessen: **2,997592 ms** (Schwelle < 1 s, bestanden mit großem
|
||||
Abstand).
|
||||
- **Frontend-Logik** (`lib/filters.test.ts`, reine Filter-/Sortierfunktion
|
||||
über 200 synthetische Aufgaben, `priority=high` + `due=overdue`):
|
||||
< 100 ms-Schwelle eingehalten (deutlich darunter, im
|
||||
Mikrosekundenbereich).
|
||||
- **Frontend-Rendering** (`TaskList.test.tsx`, vollständiges Rendern von
|
||||
200 Zeilen inkl. Ladevorgang über gemockten `fetch` in jsdom):
|
||||
gemessene Werte in zwei Durchläufen **157,6 ms** und **182,6 ms**
|
||||
(Schwelle < 2000 ms). jsdom ist üblicherweise langsamer als ein echter
|
||||
Browser, der Wert ist damit ein konservativer oberer Rand.
|
||||
- **Fazit:** Die Ansicht bleibt bei 200 Aufgaben nutzbar — sowohl die
|
||||
Datenbankabfrage als auch Filterung und Rendering liegen weit innerhalb
|
||||
spürbarer Reaktionszeiten. Keine Virtualisierung nötig/eingebaut (wäre
|
||||
bei 200 Zeilen Überdimensionierung; `web/dms-browser` nutzt dafür ein
|
||||
eigenes Muster bei 10.000 Zeilen, hier nicht übertragen, da nicht
|
||||
gefordert).
|
||||
|
||||
### 4. Drag & Drop ändert Status über TSK-01 — live nachgewiesen
|
||||
|
||||
Über Unit-/Integrationstests hinaus (siehe AK4) ein echter Lauf auf 204:
|
||||
|
||||
1. `workflow-app`-Binary gebaut, gegen ein frisches Mandanten-Schema
|
||||
gestartet (Migrationen inkl. `0016_task_status` angewendet).
|
||||
2. Aufgabe direkt über `task.Store.Create` angelegt (`Status=open`).
|
||||
3. `curl -X POST .../tasks/{id}/status -d '{"status":"in_progress"}'`
|
||||
gegen den **echten laufenden Dienst** — Antwort zeigt
|
||||
`"Status":"in_progress"`.
|
||||
4. Erneutes `GET /tasks` bestätigt den persistierten neuen Status direkt
|
||||
aus Postgres (nicht nur die Antwort des Schreibaufrufs).
|
||||
5. Next.js-App gebaut (`next build`) und mit `next start` gegen diese
|
||||
`workflow-app`-Instanz gestartet; `POST /api/tasks/{id}/status` über
|
||||
die echte BFF-Route ausgelöst, erneutes `GET /tasks` direkt gegen
|
||||
`workflow-app` bestätigt den Status `done`.
|
||||
|
||||
**Bestanden** — der komplette Weg Oberfläche → BFF → `taskapi` →
|
||||
`task.Store.SetStatus` → Postgres ist einmal vollständig real
|
||||
durchlaufen, nicht nur gemockt.
|
||||
|
||||
### 5. Kanban-Ansicht mit zwei Mandanten — nur aktueller Mandant sichtbar
|
||||
|
||||
Dreifach nachgewiesen, von der Modellebene bis zum echten Netzwerkaufruf:
|
||||
|
||||
1. **Modellebene** (`task.TestList_TenantIsolation`): zwei getrennte
|
||||
Mandanten-Schemas (`testdb.Pool`), je eine Aufgabe "Mandant A"/"Mandant
|
||||
B" angelegt — `List()` je Store liefert ausschließlich die eigene.
|
||||
2. **HTTP-Ebene** (`taskapi.TestTwoTenantServers_ListNeverCrossesTenants`):
|
||||
zwei `taskapi.Server`-Instanzen, je an ein eigenes Schema gebunden
|
||||
(genau wie `workflow-app` es produktiv je Mandant tut), Mandant B hat
|
||||
zwei Aufgaben, Mandant A eine — `GET /tasks` je Server liefert exakt
|
||||
die eigene Menge, nie die des anderen.
|
||||
3. **Live-E2E mit zwei echten laufenden Backend-Instanzen** (nicht nur
|
||||
Tests): zwei Mandanten-Schemas (`tsk04_e2e_a`, `tsk04_e2e_b`) in der
|
||||
echten Postgres-Testdatenbank angelegt, migriert, mit je einer
|
||||
Aufgabe ("Mandant A Aufgabe" / "Mandant B Aufgabe") befüllt. Zwei
|
||||
`workflow-app`-Prozesse gestartet (Port 9601 für Mandant A, 9602 für
|
||||
Mandant B, je eigene `NEXARCH_WORKFLOW_APP_TENANT_DSN` mit eigenem
|
||||
`search_path`). `curl` gegen beide Ports bestätigt: Port 9601 liefert
|
||||
ausschließlich "Mandant A Aufgabe", Port 9602 ausschließlich "Mandant B
|
||||
Aufgabe". Die Next.js-App (`next build` + `next start`) wurde mit
|
||||
`TASK_BOARD_API_URL=http://127.0.0.1:9601` (Mandant A) gestartet und
|
||||
über `curl` angesprochen:
|
||||
- `GET /api/tasks?userId=alice` → nur "Mandant A Aufgabe".
|
||||
- `GET /api/tasks?userId=alice&tenant=mandant-b` (bewusster
|
||||
Manipulationsversuch über einen erfundenen Client-Parameter) → **exakt
|
||||
dieselbe Antwort**, weiterhin nur "Mandant A Aufgabe" — die Route
|
||||
liest `tenant`/`mandant` gar nicht erst aus, es gibt keinen Pfad, über
|
||||
den ein Client das Ziel-Backend umlenken könnte.
|
||||
- `POST /api/tasks/{id}/status` über die BFF-Route verändert
|
||||
nachweislich (erneutes direktes `GET` gegen Mandant-A-Backend) nur
|
||||
Mandant A, Mandant B unverändert.
|
||||
|
||||
Alle gestarteten Prozesse anschließend beendet, die beiden
|
||||
Demo-Schemas gelöscht, das temporäre Seed-Programm
|
||||
(`workflow/cmd/e2e-tsk04-seed`, nicht Teil des Commits) entfernt.
|
||||
|
||||
**Bestanden** — die Mandantentrennung ist strukturell (ein Pool/Prozess
|
||||
pro Mandant) und wurde nicht nur behauptet, sondern mit zwei echten
|
||||
nebenläufigen Backend-Instanzen und einem echten Umgehungsversuch über
|
||||
die Oberfläche geprüft.
|
||||
|
||||
## Regressionstests
|
||||
|
||||
- Gesamtes `workflow`-Modul (`go build ./...`, `go vet ./...`,
|
||||
`go test ./... -count=1 -p 1`): alle 27 testbaren Pakete `ok`, keine
|
||||
Regression in bestehenden Paketen (`task`, `escalation`,
|
||||
`escalationnotify`, `caseobj`, `approval`, `routingrule` usw.
|
||||
unverändert grün).
|
||||
- `web/task-board`: `npx tsc --noEmit` sauber, `next build` erfolgreich
|
||||
(Turbopack, Next 16.3.5/React 19.3.0 wie alle anderen Apps seit
|
||||
OPS-11/QA-15), `npx vitest run`: **26/26 Tests bestanden**, 0
|
||||
fehlgeschlagen.
|
||||
|
||||
## QA und Entscheidungen (Iterationen: 1 von 10)
|
||||
|
||||
1. **Status-Spalte additiv in `task` (TSK-01) statt eigenem Paket:** AK4
|
||||
verlangt ausdrücklich "über TSK-01" zurückzuschreiben. Da TSK-01 bisher
|
||||
keinen Lebenszyklus-Status kannte (nur `completed_at` aus TSK-03/
|
||||
`escalation`, ein anderes Paket), war eine additive Erweiterung von
|
||||
`task.Store` um `Status`/`SetStatus` die einzige Lösung, die sowohl
|
||||
AK4 wörtlich erfüllt als auch TSK-01s eigenen Bestandscode unverändert
|
||||
lässt (gleiches Prinzip wie TSK-02/TSK-03 vorher: additive Migration,
|
||||
kein Edit an bestehenden Methoden).
|
||||
2. **`completed_at` (TSK-03) und `status=done` bewusst nicht
|
||||
verknüpft:** eine Aufgabe auf "Erledigt" im Kanban zu verschieben setzt
|
||||
nicht automatisch `escalation.Store.Complete`. Das wäre eine
|
||||
funktionale Erweiterung über TSK-04 hinaus (Interaktion zwischen zwei
|
||||
Kacheln regeln) und ist in keinem Akzeptanzkriterium verlangt — bewusst
|
||||
ausgelassen, dokumentiert statt stillschweigend verknüpft.
|
||||
3. **Keine weiteren Korrekturrunden nötig:** ein einziger Testfehler
|
||||
(ambige `getByText`-Abfrage in `TaskList.test.tsx`, traf sowohl den
|
||||
Dropdown-Eintrag "Ueberfaellig" als auch das Status-Badge) wurde in
|
||||
derselben Runde durch einen spezifischeren Selector behoben. Keine
|
||||
Backend-Iteration nötig — Go-Build/Vet/Tests liefen beim ersten
|
||||
Durchlauf durch.
|
||||
|
||||
## Nicht Teil dieser Kachel
|
||||
|
||||
- Erstellen neuer Aufgaben über die Oberfläche (kein Akzeptanzkriterium
|
||||
verlangt das; `taskapi` exponiert bewusst keinen `POST /tasks`, Aufgaben
|
||||
entstehen weiterhin über aufrufende Module/`task.Store.Create`, wie bei
|
||||
TSK-01 festgelegt).
|
||||
- Verknüpfung mit Referenzen/Fremdobjekten in der Oberfläche (TSK-01
|
||||
Akzeptanzkriterium 3 ist Backend-Scope, hier nicht dargestellt).
|
||||
- CI-Matrix-Eintrag für `task-board` (wie 10 der 13 Bestands-Apps auch
|
||||
nicht in der CI-Matrix sind, siehe `QA-15-F17-NEXTJS16-PRUEFPROTOKOLL.md`
|
||||
— keine eigene Entscheidung dieser Kachel, bestehendes Muster
|
||||
übernommen).
|
||||
|
||||
## Abschluss
|
||||
|
||||
- Commit: `TSK-04: aufgaben-oberflaeche`
|
||||
- Branch: `feature/tsk-04-aufgaben-oberflaeche`
|
||||
- Kein Merge, kein Deploy.
|
||||
@@ -32,6 +32,18 @@ const (
|
||||
PriorityHigh Priority = "high"
|
||||
)
|
||||
|
||||
// Status ist der Bearbeitungsstatus einer Aufgabe (TSK-04): Grundlage der
|
||||
// Kanban-Spalten Offen/In Bearbeitung/Erledigt. Store.SetStatus ist der
|
||||
// einzige Schreibweg -- die Kanban-Oberflaeche schreibt ausschliesslich
|
||||
// darueber zurueck (TSK-04 Akzeptanzkriterium 4).
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOpen Status = "open"
|
||||
StatusInProgress Status = "in_progress"
|
||||
StatusDone Status = "done"
|
||||
)
|
||||
|
||||
// ErrInvalidAssigneeType wird zurueckgegeben, wenn AssigneeType weder
|
||||
// "person" noch "group" ist.
|
||||
var ErrInvalidAssigneeType = errors.New("task: assignee_type muss person oder group sein")
|
||||
@@ -40,6 +52,10 @@ var ErrInvalidAssigneeType = errors.New("task: assignee_type muss person oder gr
|
||||
// zulaessigen Werte hat.
|
||||
var ErrInvalidPriority = errors.New("task: unzulaessige prioritaet")
|
||||
|
||||
// ErrInvalidStatus wird zurueckgegeben, wenn Status keinen der drei
|
||||
// zulaessigen Werte hat.
|
||||
var ErrInvalidStatus = errors.New("task: unzulaessiger status")
|
||||
|
||||
// ErrNotFound wird zurueckgegeben, wenn keine Aufgabe mit der
|
||||
// angegebenen ID existiert.
|
||||
var ErrNotFound = errors.New("task: aufgabe nicht gefunden")
|
||||
@@ -59,6 +75,7 @@ type Task struct {
|
||||
AssigneeType AssigneeType
|
||||
AssigneeID string
|
||||
Priority Priority
|
||||
Status Status
|
||||
References []Reference
|
||||
// DueDate ist die ueber calendar.Store.AddBusinessDays berechnete
|
||||
// Faelligkeit (TSK-02). Nil, solange keine Faelligkeit gesetzt wurde.
|
||||
@@ -83,8 +100,13 @@ func validPriority(p Priority) bool {
|
||||
return p == PriorityLow || p == PriorityNormal || p == PriorityHigh
|
||||
}
|
||||
|
||||
func validStatus(s Status) bool {
|
||||
return s == StatusOpen || s == StatusInProgress || s == StatusDone
|
||||
}
|
||||
|
||||
// Create legt eine Aufgabe mit optionalen Referenzen an. Ein leerer
|
||||
// Priority-Wert wird als PriorityNormal behandelt.
|
||||
// Priority-Wert wird als PriorityNormal behandelt, ein leerer Status-Wert
|
||||
// als StatusOpen.
|
||||
//
|
||||
// Zuweisung an eine Gruppe legt genau eine Zeile an (Akzeptanzkriterium 2:
|
||||
// kein Aufteilen in je eine Aufgabe pro Gruppenmitglied hier -- das waere
|
||||
@@ -100,6 +122,12 @@ func (s *Store) Create(ctx context.Context, t Task) (string, error) {
|
||||
if !validPriority(t.Priority) {
|
||||
return "", ErrInvalidPriority
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = StatusOpen
|
||||
}
|
||||
if !validStatus(t.Status) {
|
||||
return "", ErrInvalidStatus
|
||||
}
|
||||
if t.AssigneeID == "" {
|
||||
return "", fmt.Errorf("task: assignee_id darf nicht leer sein")
|
||||
}
|
||||
@@ -115,10 +143,10 @@ func (s *Store) Create(ctx context.Context, t Task) (string, error) {
|
||||
|
||||
var id string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tasks (title, assignee_type, assignee_id, priority)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO tasks (title, assignee_type, assignee_id, priority, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, t.Title, string(t.AssigneeType), t.AssigneeID, string(t.Priority)).Scan(&id); err != nil {
|
||||
`, t.Title, string(t.AssigneeType), t.AssigneeID, string(t.Priority), string(t.Status)).Scan(&id); err != nil {
|
||||
return "", fmt.Errorf("task: anlegen: %w", err)
|
||||
}
|
||||
|
||||
@@ -143,12 +171,12 @@ func (s *Store) Create(ctx context.Context, t Task) (string, error) {
|
||||
// Get liest eine Aufgabe mit ihren Referenzen.
|
||||
func (s *Store) Get(ctx context.Context, id string) (Task, error) {
|
||||
var t Task
|
||||
var assigneeType, priority string
|
||||
var assigneeType, priority, status string
|
||||
var dueDate *time.Time
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, title, assignee_type, assignee_id, priority, due_date
|
||||
SELECT id, title, assignee_type, assignee_id, priority, status, due_date
|
||||
FROM tasks WHERE id = $1
|
||||
`, id).Scan(&t.ID, &t.Title, &assigneeType, &t.AssigneeID, &priority, &dueDate); err != nil {
|
||||
`, id).Scan(&t.ID, &t.Title, &assigneeType, &t.AssigneeID, &priority, &status, &dueDate); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Task{}, ErrNotFound
|
||||
}
|
||||
@@ -156,6 +184,7 @@ func (s *Store) Get(ctx context.Context, id string) (Task, error) {
|
||||
}
|
||||
t.AssigneeType = AssigneeType(assigneeType)
|
||||
t.Priority = Priority(priority)
|
||||
t.Status = Status(status)
|
||||
t.DueDate = dueDate
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
@@ -195,6 +224,23 @@ func (s *Store) SetDueDate(ctx context.Context, id string, dueDate time.Time) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStatus setzt den Bearbeitungsstatus (TSK-04 Akzeptanzkriterium 4: der
|
||||
// einzige Schreibweg, ueber den die Kanban-Oberflaeche eine verschobene
|
||||
// Aufgabe zurueckschreibt).
|
||||
func (s *Store) SetStatus(ctx context.Context, id string, status Status) error {
|
||||
if !validStatus(status) {
|
||||
return ErrInvalidStatus
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE tasks SET status = $2 WHERE id = $1`, id, string(status))
|
||||
if err != nil {
|
||||
return fmt.Errorf("task: status setzen: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByAssignee liefert alle Aufgaben einer Person oder Gruppe.
|
||||
func (s *Store) ByAssignee(ctx context.Context, assigneeType AssigneeType, assigneeID string) ([]Task, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
@@ -264,3 +310,69 @@ func (s *Store) ByReference(ctx context.Context, moduleName, objectType, objectI
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// List liefert alle Aufgaben dieses Mandanten-Schemas (TSK-04: Grundlage
|
||||
// fuer Liste und Kanban-Ansicht), sortiert nach Faelligkeit (undatierte
|
||||
// Aufgaben zuletzt) und Erstellzeitpunkt. Bewusst zwei Abfragen statt
|
||||
// einer pro Aufgabe (wie bei ByAssignee/ByReference) -- bei 200 Aufgaben
|
||||
// (TSK-04 Pruefung 3) waere ein N+1-Zugriff auf task_references separat
|
||||
// messbar langsamer.
|
||||
func (s *Store) List(ctx context.Context) ([]Task, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, title, assignee_type, assignee_id, priority, status, due_date
|
||||
FROM tasks
|
||||
ORDER BY due_date IS NULL, due_date, created_at
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task: liste lesen: %w", err)
|
||||
}
|
||||
var tasks []Task
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var t Task
|
||||
var assigneeType, priority, status string
|
||||
if err := rows.Scan(&t.ID, &t.Title, &assigneeType, &t.AssigneeID, &priority, &status, &t.DueDate); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("task: zeile scannen: %w", err)
|
||||
}
|
||||
t.AssigneeType = AssigneeType(assigneeType)
|
||||
t.Priority = Priority(priority)
|
||||
t.Status = Status(status)
|
||||
tasks = append(tasks, t)
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("task: iterieren: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
refRows, err := s.pool.Query(ctx, `
|
||||
SELECT task_id, module_name, object_type, object_id
|
||||
FROM task_references
|
||||
WHERE task_id = ANY($1)
|
||||
ORDER BY created_at
|
||||
`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task: referenzen lesen: %w", err)
|
||||
}
|
||||
defer refRows.Close()
|
||||
byTask := make(map[string][]Reference, len(ids))
|
||||
for refRows.Next() {
|
||||
var taskID string
|
||||
var r Reference
|
||||
if err := refRows.Scan(&taskID, &r.ModuleName, &r.ObjectType, &r.ObjectID); err != nil {
|
||||
return nil, fmt.Errorf("task: referenz scannen: %w", err)
|
||||
}
|
||||
byTask[taskID] = append(byTask[taskID], r)
|
||||
}
|
||||
if err := refRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("task: referenzen iterieren: %w", err)
|
||||
}
|
||||
for i := range tasks {
|
||||
tasks[i].References = byTask[tasks[i].ID]
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCreate_DefaultsStatusOpen prueft, dass eine neu angelegte Aufgabe
|
||||
// ohne expliziten Status auf "open" steht (Grundlage der Kanban-Spalte
|
||||
// Offen, TSK-04).
|
||||
func TestCreate_DefaultsStatusOpen(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.Create(ctx, Task{Title: "Posteingang sichten", AssigneeType: AssigneePerson, AssigneeID: "user-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Status != StatusOpen {
|
||||
t.Fatalf("status = %q, want %q", got.Status, StatusOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetStatus_ValidTransition prueft TSK-04 Akzeptanzkriterium 4: der
|
||||
// Kanban-Status einer Aufgabe laesst sich ueber SetStatus (TSK-01)
|
||||
// aendern und ist danach ueber Get sichtbar.
|
||||
func TestSetStatus_ValidTransition(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.Create(ctx, Task{Title: "Vertrag pruefen", AssigneeType: AssigneePerson, AssigneeID: "user-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetStatus(ctx, id, StatusInProgress); err != nil {
|
||||
t.Fatalf("set status in_progress: %v", err)
|
||||
}
|
||||
got, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Status != StatusInProgress {
|
||||
t.Fatalf("status = %q, want %q", got.Status, StatusInProgress)
|
||||
}
|
||||
|
||||
if err := s.SetStatus(ctx, id, StatusDone); err != nil {
|
||||
t.Fatalf("set status done: %v", err)
|
||||
}
|
||||
got, err = s.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Status != StatusDone {
|
||||
t.Fatalf("status = %q, want %q", got.Status, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetStatus_RejectsInvalidStatus prueft, dass ein unzulaessiger
|
||||
// Status-Wert abgelehnt wird und die Aufgabe unveraendert bleibt.
|
||||
func TestSetStatus_RejectsInvalidStatus(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.Create(ctx, Task{Title: "x", AssigneeType: AssigneePerson, AssigneeID: "user-3"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetStatus(ctx, id, "erledigt"); !errors.Is(err, ErrInvalidStatus) {
|
||||
t.Fatalf("erwartet ErrInvalidStatus, habe %v", err)
|
||||
}
|
||||
got, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Status != StatusOpen {
|
||||
t.Fatalf("status wurde trotz abgelehnter aenderung veraendert: %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetStatus_NotFound prueft den Fehlerfall einer nicht existierenden
|
||||
// Aufgabe.
|
||||
func TestSetStatus_NotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.SetStatus(ctx, "00000000-0000-0000-0000-000000000000", StatusDone); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("erwartet ErrNotFound, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_ReturnsAllOrderedByDueDate prueft die Grundlage von TSK-04s
|
||||
// Liste/Kanban-Ansicht: List liefert alle Aufgaben des Mandanten-Schemas,
|
||||
// sortiert nach Faelligkeit, undatierte Aufgaben zuletzt, inklusive
|
||||
// Referenzen.
|
||||
func TestList_ReturnsAllOrderedByDueDate(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
idNoDue, err := s.Create(ctx, Task{Title: "ohne faelligkeit", AssigneeType: AssigneePerson, AssigneeID: "user-4"})
|
||||
if err != nil {
|
||||
t.Fatalf("create ohne faelligkeit: %v", err)
|
||||
}
|
||||
idLater, err := s.Create(ctx, Task{
|
||||
Title: "spaeter faellig", AssigneeType: AssigneePerson, AssigneeID: "user-4",
|
||||
References: []Reference{{ModuleName: "dms-fake", ObjectType: "document", ObjectID: "doc-9"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create spaeter faellig: %v", err)
|
||||
}
|
||||
idSooner, err := s.Create(ctx, Task{Title: "frueher faellig", AssigneeType: AssigneePerson, AssigneeID: "user-4"})
|
||||
if err != nil {
|
||||
t.Fatalf("create frueher faellig: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := s.SetDueDate(ctx, idLater, now.AddDate(0, 0, 10)); err != nil {
|
||||
t.Fatalf("faelligkeit setzen (spaeter): %v", err)
|
||||
}
|
||||
if err := s.SetDueDate(ctx, idSooner, now.AddDate(0, 0, 1)); err != nil {
|
||||
t.Fatalf("faelligkeit setzen (frueher): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("erwartet 3 aufgaben, habe %d", len(list))
|
||||
}
|
||||
if list[0].ID != idSooner || list[1].ID != idLater || list[2].ID != idNoDue {
|
||||
t.Fatalf("falsche reihenfolge: %s, %s, %s", list[0].ID, list[1].ID, list[2].ID)
|
||||
}
|
||||
if len(list[1].References) != 1 || list[1].References[0].ObjectID != "doc-9" {
|
||||
t.Fatalf("referenzen in liste nicht uebernommen: %+v", list[1].References)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_TenantIsolation prueft TSK-04 Akzeptanzkriterium 5 auf
|
||||
// Modellebene: zwei getrennte Mandanten-Schemas sehen sich in List
|
||||
// gegenseitig nicht, dasselbe Muster wie TestTenantIsolation.
|
||||
func TestList_TenantIsolation(t *testing.T) {
|
||||
s1 := newTestStore(t)
|
||||
s2 := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s1.Create(ctx, Task{Title: "Mandant A", AssigneeType: AssigneePerson, AssigneeID: "user-a"}); err != nil {
|
||||
t.Fatalf("create in tenant 1: %v", err)
|
||||
}
|
||||
if _, err := s2.Create(ctx, Task{Title: "Mandant B", AssigneeType: AssigneePerson, AssigneeID: "user-b"}); err != nil {
|
||||
t.Fatalf("create in tenant 2: %v", err)
|
||||
}
|
||||
|
||||
list1, err := s1.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list tenant 1: %v", err)
|
||||
}
|
||||
if len(list1) != 1 || list1[0].Title != "Mandant A" {
|
||||
t.Fatalf("mandant 1 sieht fremde aufgaben: %+v", list1)
|
||||
}
|
||||
|
||||
list2, err := s2.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list tenant 2: %v", err)
|
||||
}
|
||||
if len(list2) != 1 || list2[0].Title != "Mandant B" {
|
||||
t.Fatalf("mandant 2 sieht fremde aufgaben: %+v", list2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_PerformanceWith200Tasks misst die Laufzeit von List bei 200
|
||||
// Aufgaben (TSK-04 Pruefung 3: Performance bei 200 Aufgaben gemessen).
|
||||
// Schwelle grosszuegig (1s) -- es geht um einen belegten Messwert, nicht
|
||||
// um ein scharfes Performance-Budget auf einem Test-Host.
|
||||
func TestList_PerformanceWith200Tasks(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
if _, err := s.Create(ctx, Task{Title: "Aufgabe", AssigneeType: AssigneePerson, AssigneeID: "user-perf"}); err != nil {
|
||||
t.Fatalf("create #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
list, err := s.List(ctx)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 200 {
|
||||
t.Fatalf("erwartet 200 aufgaben, habe %d", len(list))
|
||||
}
|
||||
t.Logf("List() mit 200 Aufgaben: %s", elapsed)
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("List() mit 200 Aufgaben dauerte %s, erwartet < 1s", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package taskapi ist die HTTP-Bindung fuer das Aufgabenmodell (TSK-01)
|
||||
// zur Aufgaben-Oberflaeche (TSK-04): Liste aller Aufgaben des gebundenen
|
||||
// Mandanten-Schemas und der einzige Schreibweg fuer den Kanban-Status.
|
||||
// Nutzeridentitaet kommt uebergangsweise aus X-User-Id, wie bereits bei
|
||||
// caseapi/approvalapi (VOR-01/APR-01) etabliert.
|
||||
package taskapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/task"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
store *task.Store
|
||||
}
|
||||
|
||||
func NewServer(store *task.Store) *Server {
|
||||
return &Server{store: store}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /tasks", s.requireUser(s.handleList))
|
||||
mux.HandleFunc("POST /tasks/{id}/status", s.requireUser(s.handleSetStatus))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// handleList liefert alle Aufgaben des an diesen Server gebundenen
|
||||
// Mandanten-Schemas (TSK-04 Akzeptanzkriterium 5: der Pool ist je
|
||||
// Mandant getrennt, es gibt keinen Mandanten-Parameter, der die Antwort
|
||||
// auf einen anderen Mandanten umlenken koennte).
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request, _ string) {
|
||||
tasks, err := s.store.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "aufgaben konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
type setStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// handleSetStatus ist der Schreibweg fuer TSK-04 Akzeptanzkriterium 4:
|
||||
// Drag & Drop in der Kanban-Ansicht ruft genau diesen Endpunkt auf, der
|
||||
// seinerseits ausschliesslich task.Store.SetStatus (TSK-01) aufruft.
|
||||
func (s *Server) handleSetStatus(w http.ResponseWriter, r *http.Request, _ string) {
|
||||
id := r.PathValue("id")
|
||||
var req setStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
err := s.store.SetStatus(r.Context(), id, task.Status(req.Status))
|
||||
switch {
|
||||
case err == nil:
|
||||
t, getErr := s.store.Get(r.Context(), id)
|
||||
if getErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "status geaendert, lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
case errors.Is(err, task.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "aufgabe nicht gefunden")
|
||||
case errors.Is(err, task.ErrInvalidStatus):
|
||||
writeError(w, http.StatusUnprocessableEntity, "unzulaessiger status")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "status konnte nicht gesetzt werden")
|
||||
}
|
||||
}
|
||||
|
||||
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, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package taskapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/task"
|
||||
"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, *task.Store) {
|
||||
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)
|
||||
}
|
||||
store := task.NewStore(pool)
|
||||
return NewServer(store), store
|
||||
}
|
||||
|
||||
func TestHandleList_RequiresUser(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 ohne X-User-Id", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleList_ReturnsAllTasks prueft die Grundlage der Liste/Kanban-
|
||||
// Ansicht (TSK-04): GET /tasks liefert alle Aufgaben des gebundenen
|
||||
// Mandanten-Schemas.
|
||||
func TestHandleList_ReturnsAllTasks(t *testing.T) {
|
||||
s, store := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.Create(ctx, task.Task{Title: "Rechnung pruefen", AssigneeType: task.AssigneePerson, AssigneeID: "alice"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
||||
req.Header.Set("X-User-Id", "alice")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tasks []task.Task
|
||||
if err := json.NewDecoder(rec.Body).Decode(&tasks); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].Status != task.StatusOpen {
|
||||
t.Fatalf("tasks = %+v, want genau 1 mit status open", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSetStatus_UpdatesStatus prueft TSK-04 Akzeptanzkriterium 4:
|
||||
// der HTTP-Endpunkt, ueber den die Kanban-Ansicht beim Drag & Drop den
|
||||
// Status zurueckschreibt.
|
||||
func TestHandleSetStatus_UpdatesStatus(t *testing.T) {
|
||||
s, store := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.Create(ctx, task.Task{Title: "Postkorb", AssigneeType: task.AssigneePerson, AssigneeID: "bob"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
body := `{"status":"in_progress"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks/"+id+"/status", strings.NewReader(body))
|
||||
req.Header.Set("X-User-Id", "bob")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got task.Task
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Status != task.StatusInProgress {
|
||||
t.Fatalf("status = %q, want in_progress", got.Status)
|
||||
}
|
||||
|
||||
stored, err := store.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if stored.Status != task.StatusInProgress {
|
||||
t.Fatalf("status in der datenbank nicht aktualisiert: %q", stored.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSetStatus_RejectsInvalidStatus(t *testing.T) {
|
||||
s, store := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.Create(ctx, task.Task{Title: "x", AssigneeType: task.AssigneePerson, AssigneeID: "carol"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
body := `{"status":"erledigt"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks/"+id+"/status", strings.NewReader(body))
|
||||
req.Header.Set("X-User-Id", "carol")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, want 422", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSetStatus_NotFound(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
|
||||
body := `{"status":"done"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks/00000000-0000-0000-0000-000000000000/status", strings.NewReader(body))
|
||||
req.Header.Set("X-User-Id", "carol")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoTenantServers_ListNeverCrossesTenants ist der zentrale Beleg fuer
|
||||
// TSK-04 Akzeptanzkriterium 5 / Pruefung 5: zwei taskapi.Server-Instanzen,
|
||||
// jede an ein eigenes Mandanten-Schema gebunden (wie workflow-app es in
|
||||
// Produktion je Mandant tut, ueber NEXARCH_WORKFLOW_APP_TENANT_DSN), mit
|
||||
// Testdaten aus zwei Mandanten. GET /tasks liefert je Server ausschliess-
|
||||
// lich die Aufgaben seines eigenen Mandanten -- die Trennung ist
|
||||
// strukturell (ein Pool pro Server), nicht ein Filter, der sich durch
|
||||
// einen Client-Parameter umgehen liesse.
|
||||
func TestTwoTenantServers_ListNeverCrossesTenants(t *testing.T) {
|
||||
serverA, storeA := newTestServer(t)
|
||||
serverB, storeB := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := storeA.Create(ctx, task.Task{Title: "Mandant A Aufgabe", AssigneeType: task.AssigneePerson, AssigneeID: "user-a"}); err != nil {
|
||||
t.Fatalf("create in tenant A: %v", err)
|
||||
}
|
||||
if _, err := storeB.Create(ctx, task.Task{Title: "Mandant B Aufgabe 1", AssigneeType: task.AssigneePerson, AssigneeID: "user-b"}); err != nil {
|
||||
t.Fatalf("create in tenant B: %v", err)
|
||||
}
|
||||
if _, err := storeB.Create(ctx, task.Task{Title: "Mandant B Aufgabe 2", AssigneeType: task.AssigneePerson, AssigneeID: "user-b"}); err != nil {
|
||||
t.Fatalf("create in tenant B: %v", err)
|
||||
}
|
||||
|
||||
muxA := http.NewServeMux()
|
||||
serverA.RegisterRoutes(muxA)
|
||||
muxB := http.NewServeMux()
|
||||
serverB.RegisterRoutes(muxB)
|
||||
|
||||
reqA := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
||||
reqA.Header.Set("X-User-Id", "user-a")
|
||||
recA := httptest.NewRecorder()
|
||||
muxA.ServeHTTP(recA, reqA)
|
||||
var tasksA []task.Task
|
||||
if err := json.NewDecoder(recA.Body).Decode(&tasksA); err != nil {
|
||||
t.Fatalf("decode tenant A: %v", err)
|
||||
}
|
||||
if len(tasksA) != 1 || tasksA[0].Title != "Mandant A Aufgabe" {
|
||||
t.Fatalf("mandant A server sieht fremde aufgaben: %+v", tasksA)
|
||||
}
|
||||
|
||||
reqB := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
||||
reqB.Header.Set("X-User-Id", "user-b")
|
||||
recB := httptest.NewRecorder()
|
||||
muxB.ServeHTTP(recB, reqB)
|
||||
var tasksB []task.Task
|
||||
if err := json.NewDecoder(recB.Body).Decode(&tasksB); err != nil {
|
||||
t.Fatalf("decode tenant B: %v", err)
|
||||
}
|
||||
if len(tasksB) != 2 {
|
||||
t.Fatalf("mandant B server sieht falsche anzahl aufgaben: %+v", tasksB)
|
||||
}
|
||||
for _, tk := range tasksB {
|
||||
if !strings.HasPrefix(tk.Title, "Mandant B") {
|
||||
t.Fatalf("mandant B server sieht fremde aufgabe: %+v", tk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks DROP COLUMN status;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks ADD COLUMN status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'in_progress', 'done'));
|
||||
Reference in new issue
Block a user