Skip to content

Commit f12adb8

Browse files
committed
chore: update
1 parent 084bd53 commit f12adb8

23 files changed

Lines changed: 1056 additions & 2 deletions

.github/workflows/build-test-container.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ env:
1919

2020
jobs:
2121
maven:
22-
name: Maven build and tests
22+
name: Maven build and root tests
2323
runs-on: ubuntu-24.04
2424
outputs:
2525
app-version: ${{ steps.project-version.outputs.version }}
@@ -47,10 +47,37 @@ jobs:
4747
- name: Build application package
4848
run: ./mvnw -B package -DskipTests
4949

50+
checkpoint-states:
51+
name: Checkpoint state / ${{ matrix.state }}
52+
runs-on: ubuntu-24.04
53+
strategy:
54+
fail-fast: false
55+
matrix:
56+
state:
57+
- block-2-start
58+
- block-2-complete
59+
- block-3-start
60+
- block-3-complete
61+
steps:
62+
- name: Checkout repository
63+
uses: actions/checkout@v4
64+
65+
- name: Set up Java 24
66+
uses: actions/setup-java@v4
67+
with:
68+
distribution: temurin
69+
java-version: "24"
70+
cache: maven
71+
72+
- name: Test checkpoint state
73+
run: ./course-state test "${{ matrix.state }}"
74+
5075
container:
5176
name: Container build and publish
5277
runs-on: ubuntu-24.04
53-
needs: maven
78+
needs:
79+
- maven
80+
- checkpoint-states
5481
steps:
5582
- name: Checkout repository
5683
uses: actions/checkout@v4

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,32 @@ src/main/resources/db/migration/V2__enforce_ticket_rules.sql
8787

8888
Diese `V2` ergänzt Pflichtfelder und einen Status-Constraint für die Ticket-Tabelle.
8989

90+
## Kurs-Checkpoints
91+
92+
Der normale Unterricht startet weiterhin in diesem Starterprojekt. Für Quereinstieg, Nacharbeit oder Lösungseinsicht können definierte Arbeitsstände erzeugt werden. Diese Zustände sind keine Git-Tags oder Branches, sondern werden aus Datei-Overlays materialisiert.
93+
94+
```bash
95+
./course-state list
96+
./course-state create block-3-start ../work/db-2-app-block-3
97+
./course-state create block-3-complete ../work/db-2-app-block-3-loesung
98+
```
99+
100+
| Zustand | Bedeutung |
101+
| --- | --- |
102+
| `block-2-start` | Starter für Block 2 mit schwacher `V1` |
103+
| `block-2-complete` | Lösung mit `V2__enforce_ticket_rules.sql` |
104+
| `block-3-start` | Einstieg in Block 3 auf Basis von Block 2 |
105+
| `block-3-complete` | Lösung mit Ticket-Kommentaren, Events, Transaktionen und `@Version` |
106+
107+
Dozierende können die Zustände prüfen:
108+
109+
```bash
110+
./course-state test block-3-complete
111+
./course-state validate --skip-slow
112+
```
113+
114+
Ohne `--skip-slow` werden zusätzlich Testcontainers-Tests ausgeführt, wenn Docker oder Podman verfügbar ist.
115+
90116
## Teststrategie
91117

92118
- `./mvnw test`: schnelle Tests ohne Docker.

course-state

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require("fs");
4+
const os = require("os");
5+
const path = require("path");
6+
const { spawnSync } = require("child_process");
7+
8+
const root = __dirname;
9+
const courseDir = path.join(root, "course");
10+
const statesFile = path.join(courseDir, "states.yml");
11+
const overlaysDir = path.join(courseDir, "overlays");
12+
13+
function usage() {
14+
console.log(`Usage:
15+
./course-state list
16+
./course-state create <state> <target-dir>
17+
./course-state test <state> [--skip-slow]
18+
./course-state validate [--skip-slow]`);
19+
}
20+
21+
function parseStates() {
22+
const lines = fs.readFileSync(statesFile, "utf8").split(/\r?\n/);
23+
const states = {};
24+
let current = null;
25+
let listKey = null;
26+
27+
for (const line of lines) {
28+
if (!line.trim() || line.trim().startsWith("#") || line.trim() === "states:") {
29+
continue;
30+
}
31+
32+
const stateMatch = line.match(/^ ([a-z0-9-]+):\s*$/);
33+
if (stateMatch) {
34+
current = { overlays: [], tests: [], expectedFailures: [] };
35+
states[stateMatch[1]] = current;
36+
listKey = null;
37+
continue;
38+
}
39+
40+
if (!current) {
41+
continue;
42+
}
43+
44+
const keyMatch = line.match(/^ ([A-Za-z]+):(?:\s*(.*))?$/);
45+
if (keyMatch) {
46+
const key = keyMatch[1];
47+
const value = (keyMatch[2] || "").trim();
48+
listKey = null;
49+
50+
if (["overlays", "tests", "expectedFailures"].includes(key)) {
51+
listKey = key;
52+
if (value === "[]") {
53+
current[key] = [];
54+
listKey = null;
55+
}
56+
continue;
57+
}
58+
59+
if (key === "description" || key === "extends") {
60+
current[key] = unquote(value);
61+
}
62+
continue;
63+
}
64+
65+
const itemMatch = line.match(/^ -\s*(.*)$/);
66+
if (itemMatch && listKey) {
67+
current[listKey].push(unquote(itemMatch[1].trim()));
68+
}
69+
}
70+
71+
return states;
72+
}
73+
74+
function unquote(value) {
75+
return value.replace(/^["']|["']$/g, "");
76+
}
77+
78+
function resolveState(states, name, seen = new Set()) {
79+
const state = states[name];
80+
if (!state) {
81+
throw new Error(`Unknown state: ${name}`);
82+
}
83+
if (seen.has(name)) {
84+
throw new Error(`Cycle in course state definitions at ${name}`);
85+
}
86+
seen.add(name);
87+
88+
if (!state.extends) {
89+
return { ...state, overlays: [...state.overlays] };
90+
}
91+
92+
const parent = resolveState(states, state.extends, seen);
93+
return {
94+
...state,
95+
overlays: [...parent.overlays, ...state.overlays],
96+
tests: state.tests.length ? state.tests : parent.tests,
97+
expectedFailures: state.expectedFailures.length ? state.expectedFailures : parent.expectedFailures,
98+
};
99+
}
100+
101+
function copyProject(target) {
102+
const resolvedTarget = path.resolve(target);
103+
if (resolvedTarget === root || resolvedTarget.startsWith(root + path.sep)) {
104+
throw new Error("Target directory must be outside db-2-app.");
105+
}
106+
fs.rmSync(resolvedTarget, { recursive: true, force: true });
107+
fs.mkdirSync(resolvedTarget, { recursive: true });
108+
copyDir(root, resolvedTarget, new Set([".git", "target", ".idea", ".vscode"]));
109+
}
110+
111+
function copyDir(source, target, excludedNames = new Set()) {
112+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
113+
if (excludedNames.has(entry.name)) {
114+
continue;
115+
}
116+
const sourcePath = path.join(source, entry.name);
117+
const targetPath = path.join(target, entry.name);
118+
if (entry.isDirectory()) {
119+
fs.mkdirSync(targetPath, { recursive: true });
120+
copyDir(sourcePath, targetPath, excludedNames);
121+
} else if (entry.isSymbolicLink()) {
122+
fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath);
123+
} else {
124+
fs.copyFileSync(sourcePath, targetPath);
125+
}
126+
}
127+
}
128+
129+
function applyOverlay(target, overlay) {
130+
const overlayPath = path.join(overlaysDir, overlay);
131+
if (!fs.existsSync(overlayPath)) {
132+
throw new Error(`Missing overlay: ${overlay}`);
133+
}
134+
copyDir(overlayPath, target);
135+
}
136+
137+
function createState(name, target) {
138+
const states = parseStates();
139+
const state = resolveState(states, name);
140+
const resolvedTarget = path.resolve(target);
141+
copyProject(resolvedTarget);
142+
for (const overlay of state.overlays) {
143+
applyOverlay(resolvedTarget, overlay);
144+
}
145+
fs.writeFileSync(
146+
path.join(resolvedTarget, "COURSE_STATE.md"),
147+
`# ${name}\n\n${state.description || ""}\n\nApplied overlays: ${state.overlays.join(", ") || "none"}\n`,
148+
"utf8"
149+
);
150+
console.log(`Created ${name} at ${resolvedTarget}`);
151+
}
152+
153+
function validate(skipSlow) {
154+
const states = parseStates();
155+
const baseTempDir = fs.existsSync("/tmp") ? "/tmp" : os.tmpdir();
156+
const tempRoot = fs.mkdtempSync(path.join(baseTempDir, "db-2-app-states-"));
157+
try {
158+
for (const name of Object.keys(states)) {
159+
testState(name, path.join(tempRoot, name), skipSlow);
160+
}
161+
} finally {
162+
fs.rmSync(tempRoot, { recursive: true, force: true });
163+
}
164+
}
165+
166+
function testState(name, target, skipSlow) {
167+
const states = parseStates();
168+
const state = resolveState(states, name);
169+
createState(name, target);
170+
runCommands(name, target, state.tests, false, skipSlow);
171+
runCommands(name, target, state.expectedFailures || [], true, skipSlow);
172+
}
173+
174+
function runCommands(stateName, cwd, commands, expectedFailure, skipSlow) {
175+
for (const command of commands) {
176+
if (skipSlow && command.includes("-Ptestcontainers")) {
177+
console.log(`[${stateName}] skip slow command: ${command}`);
178+
continue;
179+
}
180+
console.log(`[${stateName}] ${expectedFailure ? "expect failure: " : ""}${command}`);
181+
const result = spawnSync(command, {
182+
cwd,
183+
shell: true,
184+
stdio: "inherit",
185+
});
186+
const failed = result.status !== 0;
187+
if (expectedFailure ? !failed : failed) {
188+
throw new Error(`[${stateName}] Unexpected result for: ${command}`);
189+
}
190+
}
191+
}
192+
193+
const [command, ...args] = process.argv.slice(2);
194+
195+
try {
196+
if (command === "list") {
197+
for (const [name, state] of Object.entries(parseStates())) {
198+
console.log(`${name}: ${state.description || ""}`);
199+
}
200+
} else if (command === "create") {
201+
if (args.length !== 2) {
202+
usage();
203+
process.exit(1);
204+
}
205+
createState(args[0], args[1]);
206+
} else if (command === "test") {
207+
const stateName = args.find((arg) => !arg.startsWith("--"));
208+
if (!stateName) {
209+
usage();
210+
process.exit(1);
211+
}
212+
const baseTempDir = fs.existsSync("/tmp") ? "/tmp" : os.tmpdir();
213+
const target = fs.mkdtempSync(path.join(baseTempDir, `db-2-app-${stateName}-`));
214+
try {
215+
testState(stateName, target, args.includes("--skip-slow"));
216+
} finally {
217+
fs.rmSync(target, { recursive: true, force: true });
218+
}
219+
} else if (command === "validate") {
220+
validate(args.includes("--skip-slow"));
221+
} else {
222+
usage();
223+
process.exit(command ? 1 : 0);
224+
}
225+
} catch (error) {
226+
console.error(error.message);
227+
process.exit(1);
228+
}

course/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Kurs-Checkpoints
2+
3+
Der normale Unterricht startet weiterhin im Root von `db-2-app`. Die Checkpoints sind Hilfszustaende fuer Quereinstieg, Nacharbeit und Loesungseinsicht.
4+
5+
## Zustaende
6+
7+
| Zustand | Bedeutung |
8+
| --- | --- |
9+
| `block-2-start` | Starter fuer Block 2 mit bewusst schwacher `V1` |
10+
| `block-2-complete` | Block-2-Loesung mit `V2__enforce_ticket_rules.sql` |
11+
| `block-3-start` | Einstieg in Block 3 auf Basis von `block-2-complete` |
12+
| `block-3-complete` | Block-3-Loesung mit Transaktionsworkflow |
13+
14+
## Arbeitskopie erzeugen
15+
16+
```bash
17+
./course-state create block-3-start ../work/db-2-app-block-3
18+
```
19+
20+
Die Zielkopie ist ein normales Maven-Projekt. Sie darf bearbeitet, getestet und wieder geloescht werden.
21+
22+
## Checkpoints pruefen
23+
24+
Einen einzelnen Zustand pruefen:
25+
26+
```bash
27+
./course-state test block-3-complete
28+
```
29+
30+
Alle Zustaende pruefen:
31+
32+
```bash
33+
./course-state validate
34+
```
35+
36+
Fuer schnelle lokale Pruefung ohne Testcontainers:
37+
38+
```bash
39+
./course-state validate --skip-slow
40+
```
41+
42+
Die Definitionen liegen in `course/states.yml`. Die Overlays bestehen aus normalen Dateien unter `course/overlays/`.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
UPDATE app_starter.tickets
2+
SET title = 'Ohne Titel'
3+
WHERE title IS NULL OR btrim(title) = '';
4+
5+
UPDATE app_starter.tickets
6+
SET status = 'open'
7+
WHERE status IS NULL OR status NOT IN ('open', 'waiting', 'closed');
8+
9+
ALTER TABLE app_starter.tickets
10+
ALTER COLUMN title SET NOT NULL;
11+
12+
ALTER TABLE app_starter.tickets
13+
ALTER COLUMN status SET NOT NULL;
14+
15+
ALTER TABLE app_starter.tickets
16+
ADD CONSTRAINT tickets_status_check
17+
CHECK (status IN ('open', 'waiting', 'closed'));
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package ch.hftm.db2.ticketsystem.ticket;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Data;
7+
import lombok.NoArgsConstructor;
8+
9+
@Data
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
@Schema(description = "Eingabe für ein neues Ticket")
13+
class CreateTicketRequest {
14+
15+
@NotBlank
16+
@Schema(description = "Kurzer Tickettitel", example = "Datenbankverbindung pruefen")
17+
private String title;
18+
19+
@NotBlank
20+
@Schema(description = "Ticketstatus", example = "open")
21+
private String status;
22+
23+
@Schema(description = "Optionaler erster Kommentar", example = "Fehler tritt seit heute Morgen auf.")
24+
private String initialComment;
25+
}

0 commit comments

Comments
 (0)