Compare commits
12 Commits
worktree-c
...
d8de391c10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8de391c10 | ||
|
|
5dd91fa185 | ||
|
|
2ad25f069c | ||
|
|
bbbdf8cd09 | ||
|
|
f727429699 | ||
|
|
e268e2dbca | ||
|
|
3de0d2f0fe | ||
|
|
0abbc147e2 | ||
|
|
6210480952 | ||
|
|
e17f4110f1 | ||
|
|
fa46492759 | ||
|
|
3965541879 |
@@ -2,6 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -56,6 +57,26 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Assert no (upload|download)-artifact past v3
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Self-test: verify the regex catches v4+ and does not catch v3.
|
||||||
|
tmp=$(mktemp)
|
||||||
|
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
|
||||||
|
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
|
||||||
|
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
|
||||||
|
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
|
||||||
|
rm "$tmp"
|
||||||
|
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
|
||||||
|
# Both upload-artifact and download-artifact share the same incompatibility.
|
||||||
|
# Pin to @v3. See ADR-014 / #557.
|
||||||
|
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
|
||||||
|
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run unit and component tests with coverage
|
- name: Run unit and component tests with coverage
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -77,9 +98,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload coverage reports
|
- name: Upload coverage reports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-reports
|
name: coverage-reports
|
||||||
path: |
|
path: |
|
||||||
@@ -113,9 +135,10 @@ jobs:
|
|||||||
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
|
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
|
||||||
echo "PASS: only /hilfe/transkription.html prerendered."
|
echo "PASS: only /hilfe/transkription.html prerendered."
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: unit-test-screenshots
|
name: unit-test-screenshots
|
||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
@@ -269,6 +292,7 @@ jobs:
|
|||||||
MAIL_HOST=mailpit
|
MAIL_HOST=mailpit
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
APP_MAIL_FROM=noreply@local
|
APP_MAIL_FROM=noreply@local
|
||||||
|
IMPORT_HOST_DIR=/tmp/dummy-import
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Bring up minio
|
- name: Bring up minio
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload coverage log on failure
|
- name: Upload coverage log on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-log-run-${{ matrix.run }}
|
name: coverage-log-run-${{ matrix.run }}
|
||||||
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ public class MassImportService {
|
|||||||
|
|
||||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||||
|
|
||||||
public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {}
|
public record ImportStatus(State state, String statusCode, String message, int processed, LocalDateTime startedAt) {}
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null);
|
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
public ImportStatus getStatus() {
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
@@ -116,20 +116,29 @@ public class MassImportService {
|
|||||||
if (currentStatus.state() == State.RUNNING) {
|
if (currentStatus.state() == State.RUNNING) {
|
||||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||||
}
|
}
|
||||||
currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now());
|
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
||||||
try {
|
try {
|
||||||
File spreadsheet = findSpreadsheetFile();
|
File spreadsheet = findSpreadsheetFile();
|
||||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
int processed = processRows(readSpreadsheet(spreadsheet));
|
||||||
currentStatus = new ImportStatus(State.DONE,
|
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
||||||
processed, currentStatus.startedAt());
|
processed, currentStatus.startedAt());
|
||||||
|
} catch (NoSpreadsheetException e) {
|
||||||
|
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||||
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||||
|
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Massenimport fehlgeschlagen", e);
|
log.error("Massenimport fehlgeschlagen", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||||
|
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class NoSpreadsheetException extends RuntimeException {
|
||||||
|
NoSpreadsheetException(String message) { super(message); }
|
||||||
|
}
|
||||||
|
|
||||||
private File findSpreadsheetFile() throws IOException {
|
private File findSpreadsheetFile() throws IOException {
|
||||||
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
|
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
|
||||||
return files
|
return files
|
||||||
@@ -138,7 +147,7 @@ public class MassImportService {
|
|||||||
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
|
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
|
||||||
})
|
})
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow(() -> new RuntimeException(
|
.orElseThrow(() -> new NoSpreadsheetException(
|
||||||
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
|
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
|
||||||
.toFile();
|
.toFile();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,20 @@ class MassImportServiceTest {
|
|||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── runImportAsync ───────────────────────────────────────────────────────
|
// ─── runImportAsync ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
||||||
// /import directory doesn't exist in test environment → findSpreadsheetFile throws
|
// /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
|
||||||
service.runImportAsync();
|
service.runImportAsync();
|
||||||
|
|
||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -93,10 +99,19 @@ class MassImportServiceTest {
|
|||||||
assertThat(service.getStatus().message()).contains(tempDir.toString());
|
assertThat(service.getStatus().message()).contains(tempDir.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
|
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ class AdminControllerTest {
|
|||||||
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
// ─── GET /api/admin/import-status ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||||
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||||
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.state").value("IDLE"))
|
||||||
|
.andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE"))
|
||||||
|
.andExpect(jsonPath("$.processed").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
|||||||
122
docs/adr/014-upload-artifact-v3-pin.md
Normal file
122
docs/adr/014-upload-artifact-v3-pin.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# ADR 014 — Pin actions/upload-artifact to v3 (Gitea act_runner v4 protocol incompatibility)
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-05-14
|
||||||
|
**Issues:** [#557 — re-regression](https://git.raddatz.cloud/marcel/familienarchiv/issues/557) · [#14 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/14)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`actions/upload-artifact` is available in two incompatible major versions. The v4 client
|
||||||
|
uploads via a GitHub-specific artifact API that is **not implemented** in Gitea's
|
||||||
|
`act_runner` (the self-hosted CI substrate established by ADR-011). When a workflow step
|
||||||
|
uses `actions/upload-artifact@v4` on this runner, `act_runner` returns a non-zero exit
|
||||||
|
code from the v4 client even when all tests pass, producing:
|
||||||
|
|
||||||
|
> green test suite — red job status — no artifact uploaded
|
||||||
|
|
||||||
|
The failure lands in the upload step, _after_ the test output, making it hard to diagnose
|
||||||
|
from the build log.
|
||||||
|
|
||||||
|
### Incident history
|
||||||
|
|
||||||
|
| Date | Commit | Event |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-03-19 | `9f3f022e` | Original downgrade: `upload-artifact@v4 → v3` |
|
||||||
|
| 2026-03-19 | `4142c7cd` | Rationale committed; closes #14 |
|
||||||
|
| 2026-05-05 | `410b91e2` | Re-regression: upgraded back to v4 without referencing #14 |
|
||||||
|
| 2026-05-14 | this PR | Second downgrade + ADR + grep guard |
|
||||||
|
|
||||||
|
The root cause of the re-regression was institutional-memory failure: the original
|
||||||
|
rationale was captured only in a commit body, invisible at the point of change (the
|
||||||
|
`uses:` line). This ADR, the inline comments, and the grep guard are the three
|
||||||
|
defence layers that replace that missing breadcrumb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Pin all `actions/upload-artifact` and `actions/download-artifact` call sites to `@v3`.**
|
||||||
|
|
||||||
|
Both action families share the same v4 protocol incompatibility with `act_runner`.
|
||||||
|
Pinning to the major tag (`@v3`) keeps us on the latest v3 patch without Renovate noise.
|
||||||
|
|
||||||
|
Three call sites are pinned:
|
||||||
|
- `.gitea/workflows/ci.yml` — "Upload coverage reports" step
|
||||||
|
- `.gitea/workflows/ci.yml` — "Upload screenshots" step
|
||||||
|
- `.gitea/workflows/coverage-flake-probe.yml` — "Upload coverage log on failure" step
|
||||||
|
|
||||||
|
Each pinned `uses:` line carries a load-bearing inline comment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
```
|
||||||
|
|
||||||
|
A CI grep guard enforces the constraint automatically (see below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Enforcement layers (defence in depth)
|
||||||
|
|
||||||
|
1. **Inline comments** on every `uses:` line — visible at the point of change.
|
||||||
|
2. **CI grep guard** in `.gitea/workflows/ci.yml` ("Assert no (upload|download)-artifact
|
||||||
|
past v3") — fails the build if a future commit re-introduces `@v4` or higher on any
|
||||||
|
workflow file. Anchored to YAML `uses:` lines to avoid false positives on embedded
|
||||||
|
shell strings. Includes a self-test that proves the regex catches v4+ before scanning
|
||||||
|
the repo.
|
||||||
|
3. **This ADR** — canonical rationale; cross-referenced by comments and guard message.
|
||||||
|
|
||||||
|
### How to spot the symptom
|
||||||
|
|
||||||
|
- Test suite output shows green (vitest, surefire, pytest all exit 0)
|
||||||
|
- CI job status shows red
|
||||||
|
- Artifacts section of the run is empty
|
||||||
|
- Build log shows a non-zero exit from the `Upload …` step immediately after green tests
|
||||||
|
|
||||||
|
### `@v3` maintenance-mode status
|
||||||
|
|
||||||
|
GitHub placed `actions/upload-artifact@v3` in maintenance mode (no new features) but it
|
||||||
|
has not been removed and carries no known unpatched CVE as of this writing. If GitHub
|
||||||
|
publishes a v3-specific security advisory, that is an additional trigger to re-evaluate
|
||||||
|
(see upgrade conditions below).
|
||||||
|
|
||||||
|
### When to remove this pin
|
||||||
|
|
||||||
|
Re-evaluate pinning **when either condition is met:**
|
||||||
|
|
||||||
|
1. `gitea/act_runner` ships a release with v4 artifact protocol support. Track upstream:
|
||||||
|
<https://gitea.com/gitea/act_runner>
|
||||||
|
2. `actions/upload-artifact@v3` acquires an unpatched CVE that cannot be mitigated
|
||||||
|
at the runner level.
|
||||||
|
|
||||||
|
When upgrading: remove the grep guard step, update all three `uses:` lines, remove the
|
||||||
|
inline comments, and update this ADR's status to Superseded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
### SHA pinning (`uses: actions/upload-artifact@<sha>`)
|
||||||
|
|
||||||
|
More secure against action repository compromise, but adds Renovate update friction
|
||||||
|
and is disproportionate for a self-hosted, single-tenant Gitea instance with one
|
||||||
|
trusted contributor (ADR-011). Rejected.
|
||||||
|
|
||||||
|
### Minor/patch pinning (`@v3.4.0`)
|
||||||
|
|
||||||
|
Avoids Renovate PRs but freezes us on a specific patch. The v3 major track is in
|
||||||
|
maintenance mode — minor pinning has no benefit and would require manual updates
|
||||||
|
for any v3 security patches. Rejected.
|
||||||
|
|
||||||
|
### Renovate `packageRules` bypass
|
||||||
|
|
||||||
|
Would prevent automated PRs from proposing v4. Not needed while Renovate is not
|
||||||
|
configured for this repository. Revisit if Renovate is introduced.
|
||||||
|
|
||||||
|
### Migrating the runner to a v4-compatible Gitea release
|
||||||
|
|
||||||
|
Out of scope for this issue. A separate decision; tracked in #557's non-goals.
|
||||||
@@ -200,7 +200,7 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||||
with:
|
with:
|
||||||
name: unit-test-screenshots
|
name: unit-test-screenshots
|
||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
@@ -227,7 +227,7 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||||
with:
|
with:
|
||||||
name: backend-test-results
|
name: backend-test-results
|
||||||
path: backend/target/surefire-reports/
|
path: backend/target/surefire-reports/
|
||||||
@@ -329,7 +329,7 @@ jobs:
|
|||||||
E2E_BACKEND_URL: http://localhost:8080
|
E2E_BACKEND_URL: http://localhost:8080
|
||||||
- name: Upload E2E results
|
- name: Upload E2E results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||||
with:
|
with:
|
||||||
name: e2e-results
|
name: e2e-results
|
||||||
path: frontend/test-results/e2e/
|
path: frontend/test-results/e2e/
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ npm run check # svelte-check (type checking)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test # Vitest unit + server tests (headless)
|
npm run test # Vitest unit + server tests (headless)
|
||||||
npm run test:coverage # Coverage report (server project only)
|
npm run test:coverage # Coverage report (server + client)
|
||||||
npm run test:e2e # Playwright E2E tests
|
npm run test:e2e # Playwright E2E tests
|
||||||
npm run test:e2e:headed # Playwright E2E with visible browser
|
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||||
npm run test:e2e:ui # Playwright UI mode
|
npm run test:e2e:ui # Playwright UI mode
|
||||||
|
|||||||
@@ -345,8 +345,11 @@
|
|||||||
"admin_system_import_btn_retry": "Erneut starten",
|
"admin_system_import_btn_retry": "Erneut starten",
|
||||||
"admin_system_import_status_idle": "Kein Import gestartet.",
|
"admin_system_import_status_idle": "Kein Import gestartet.",
|
||||||
"admin_system_import_status_running": "Import läuft…",
|
"admin_system_import_status_running": "Import läuft…",
|
||||||
"admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.",
|
"admin_system_import_status_done": "Import abgeschlossen",
|
||||||
"admin_system_import_status_failed": "Fehler: {message}",
|
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||||
|
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||||
|
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||||
|
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||||
"admin_system_thumbnails_heading": "Thumbnails erzeugen",
|
"admin_system_thumbnails_heading": "Thumbnails erzeugen",
|
||||||
"admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).",
|
"admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).",
|
||||||
"admin_system_thumbnails_btn_start": "Thumbnails erzeugen",
|
"admin_system_thumbnails_btn_start": "Thumbnails erzeugen",
|
||||||
|
|||||||
@@ -345,8 +345,11 @@
|
|||||||
"admin_system_import_btn_retry": "Start again",
|
"admin_system_import_btn_retry": "Start again",
|
||||||
"admin_system_import_status_idle": "No import started.",
|
"admin_system_import_status_idle": "No import started.",
|
||||||
"admin_system_import_status_running": "Import running…",
|
"admin_system_import_status_running": "Import running…",
|
||||||
"admin_system_import_status_done": "Import complete – {count} documents processed.",
|
"admin_system_import_status_done": "Import complete",
|
||||||
"admin_system_import_status_failed": "Error: {message}",
|
"admin_system_import_status_done_label": "Documents processed",
|
||||||
|
"admin_system_import_status_failed": "Import failed",
|
||||||
|
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||||
|
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||||
"admin_system_thumbnails_heading": "Generate thumbnails",
|
"admin_system_thumbnails_heading": "Generate thumbnails",
|
||||||
"admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).",
|
"admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).",
|
||||||
"admin_system_thumbnails_btn_start": "Generate thumbnails",
|
"admin_system_thumbnails_btn_start": "Generate thumbnails",
|
||||||
|
|||||||
@@ -345,8 +345,11 @@
|
|||||||
"admin_system_import_btn_retry": "Iniciar de nuevo",
|
"admin_system_import_btn_retry": "Iniciar de nuevo",
|
||||||
"admin_system_import_status_idle": "No hay importación iniciada.",
|
"admin_system_import_status_idle": "No hay importación iniciada.",
|
||||||
"admin_system_import_status_running": "Importación en curso…",
|
"admin_system_import_status_running": "Importación en curso…",
|
||||||
"admin_system_import_status_done": "Importación completada – {count} documentos procesados.",
|
"admin_system_import_status_done": "Importación completada",
|
||||||
"admin_system_import_status_failed": "Error: {message}",
|
"admin_system_import_status_done_label": "Documentos procesados",
|
||||||
|
"admin_system_import_status_failed": "Importación fallida",
|
||||||
|
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||||
|
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||||
"admin_system_thumbnails_heading": "Generar miniaturas",
|
"admin_system_thumbnails_heading": "Generar miniaturas",
|
||||||
"admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).",
|
"admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).",
|
||||||
"admin_system_thumbnails_btn_start": "Generar miniaturas",
|
"admin_system_thumbnails_btn_start": "Generar miniaturas",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
|
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import ImportStatusCard from './ImportStatusCard.svelte';
|
||||||
|
|
||||||
let backfillResult: number | null = $state(null);
|
let backfillResult: number | null = $state(null);
|
||||||
let backfillLoading = $state(false);
|
let backfillLoading = $state(false);
|
||||||
@@ -9,6 +10,7 @@ let backfillHashesLoading = $state(false);
|
|||||||
|
|
||||||
type ImportStatus = {
|
type ImportStatus = {
|
||||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||||
|
statusCode: string;
|
||||||
message: string;
|
message: string;
|
||||||
processed: number;
|
processed: number;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
@@ -177,47 +179,7 @@ async function backfillFileHashes() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mass import -->
|
<!-- Mass import -->
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<ImportStatusCard importStatus={importStatus} ontrigger={triggerImport} />
|
||||||
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
|
|
||||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
|
|
||||||
|
|
||||||
{#if importStatus?.state === 'RUNNING'}
|
|
||||||
<p class="text-sm text-ink-2">{m.admin_system_import_status_running()}</p>
|
|
||||||
{:else if importStatus?.state === 'DONE'}
|
|
||||||
<p class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
|
||||||
{m.admin_system_import_status_done({ count: importStatus.processed })}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
data-import-trigger
|
|
||||||
onclick={triggerImport}
|
|
||||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
{m.admin_system_import_btn_retry()}
|
|
||||||
</button>
|
|
||||||
{:else if importStatus?.state === 'FAILED'}
|
|
||||||
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
||||||
{m.admin_system_import_status_failed({ message: importStatus.message })}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
data-import-trigger
|
|
||||||
onclick={triggerImport}
|
|
||||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
{m.admin_system_import_btn_retry()}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
{#if importStatus !== null}
|
|
||||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
data-import-trigger
|
|
||||||
onclick={triggerImport}
|
|
||||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
{m.admin_system_import_btn_start()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Thumbnail backfill -->
|
<!-- Thumbnail backfill -->
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
|||||||
80
frontend/src/routes/admin/system/ImportStatusCard.svelte
Normal file
80
frontend/src/routes/admin/system/ImportStatusCard.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type ImportStatus = {
|
||||||
|
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||||
|
statusCode: string;
|
||||||
|
message: string;
|
||||||
|
processed: number;
|
||||||
|
startedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
importStatus,
|
||||||
|
ontrigger
|
||||||
|
}: {
|
||||||
|
importStatus: ImportStatus | null;
|
||||||
|
ontrigger: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
|
||||||
|
|
||||||
|
{#if importStatus?.state === 'RUNNING'}
|
||||||
|
<div class="mb-4 flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
data-testid="spinner"
|
||||||
|
role="status"
|
||||||
|
aria-label={m.admin_system_import_status_running()}
|
||||||
|
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-ink-3 border-t-brand-mint"
|
||||||
|
></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-base font-bold text-ink">{importStatus.processed}</p>
|
||||||
|
<p class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.admin_system_import_status_running()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if importStatus?.state === 'DONE'}
|
||||||
|
<div class="mb-4 rounded-sm border border-green-200 bg-green-50 p-4 text-green-700">
|
||||||
|
<p class="text-base font-bold">{importStatus.processed}</p>
|
||||||
|
<p class="text-xs font-bold tracking-widest text-green-600 uppercase">
|
||||||
|
{m.admin_system_import_status_done_label()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-green-600">{m.admin_system_import_status_done()}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
data-import-trigger
|
||||||
|
onclick={ontrigger}
|
||||||
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.admin_system_import_btn_retry()}
|
||||||
|
</button>
|
||||||
|
{:else if importStatus?.state === 'FAILED'}
|
||||||
|
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{importStatus.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET'
|
||||||
|
? m.admin_system_import_failed_no_spreadsheet()
|
||||||
|
: m.admin_system_import_failed_internal()}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
data-import-trigger
|
||||||
|
onclick={ontrigger}
|
||||||
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.admin_system_import_btn_retry()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{#if importStatus !== null}
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
data-import-trigger
|
||||||
|
onclick={ontrigger}
|
||||||
|
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.admin_system_import_btn_start()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { expect } from '@vitest/browser/context';
|
||||||
|
import ImportStatusCard from './ImportStatusCard.svelte';
|
||||||
|
|
||||||
|
type ImportStatus = {
|
||||||
|
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||||
|
statusCode: string;
|
||||||
|
message: string;
|
||||||
|
processed: number;
|
||||||
|
startedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
|
||||||
|
state: 'IDLE',
|
||||||
|
statusCode: 'IMPORT_IDLE',
|
||||||
|
message: '',
|
||||||
|
processed: 0,
|
||||||
|
startedAt: null,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImportStatusCard', () => {
|
||||||
|
it('shows spinner while state is RUNNING', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 3 }),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('spinner')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows processed count at text-base while RUNNING', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 7 }),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('7')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows processed count while DONE', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 42 }),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('42')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no-spreadsheet message when statusCode is IMPORT_FAILED_NO_SPREADSHEET', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'FAILED',
|
||||||
|
statusCode: 'IMPORT_FAILED_NO_SPREADSHEET',
|
||||||
|
message: 'Keine Tabellendatei...'
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('No spreadsheet file found.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { createRawSnippet } from 'svelte';
|
import { createRawSnippet } from 'svelte';
|
||||||
|
|
||||||
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
||||||
@@ -96,13 +96,13 @@ describe('Layout – user dropdown', () => {
|
|||||||
|
|
||||||
it('opens dropdown on button click', async () => {
|
it('opens dropdown on button click', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('profile link points to /profile', async () => {
|
it('profile link points to /profile', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /Profil/i }))
|
.element(page.getByRole('link', { name: /Profil/i }))
|
||||||
.toHaveAttribute('href', '/profile');
|
.toHaveAttribute('href', '/profile');
|
||||||
@@ -110,16 +110,16 @@ describe('Layout – user dropdown', () => {
|
|||||||
|
|
||||||
it('logout button is in the dropdown', async () => {
|
it('logout button is in the dropdown', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
await page.getByRole('button', { name: /MM/ }).click();
|
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
|
||||||
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes dropdown when Escape is pressed', async () => {
|
it('closes dropdown when Escape is pressed', async () => {
|
||||||
render(Layout, { data: makeData(), children: emptySnippet });
|
render(Layout, { data: makeData(), children: emptySnippet });
|
||||||
const btn = page.getByRole('button', { name: /MM/ });
|
const btnEl = (await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement;
|
||||||
await btn.click();
|
btnEl.click();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
|
||||||
await userEvent.keyboard('{Escape}');
|
btnEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user