Compare commits

..

13 Commits

Author SHA1 Message Date
Marcel
a23fa4c668 fix(login): add role=alert to error divs; fix clock icon color to red
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m3s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m4s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
Regular error div was missing role="alert" — screen readers did not
announce it on dynamic display. Rate-limited clock icon used text-ink-3
(muted grey) instead of text-red-600, visually inconsistent with the
surrounding error text. Also removes the erroneous aria-invalid="true"
from the rate-limit alert div (not a permitted attribute on role=alert).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:02:24 +02:00
Marcel
05ab8b13a0 docs(arch): update auth sequence diagram to Phase 2 (CSRF, rate limit, revocation)
Extends the diagram from ADR-020 Phase 1 to cover:
- Rate limiter gate before credential validation in login
- CSRF double-submit cookie handshake for mutating requests
- Session revocation on password change (revokeOtherSessions) and
  password reset (revokeAllSessions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:41:15 +02:00
Marcel
1052295a6e docs(adr): add ADR-022 for CSRF, session revocation, and rate limiting
Documents the double-submit cookie CSRF pattern, sequential token-bucket
rate limiter with refund mechanic, and session revocation on password
change/reset — all implemented as part of issue #524.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:40:19 +02:00
Marcel
c3d1bea623 refactor(security): extract static ERROR_WRITER; update ADR ref to ADR-022
Replaces per-invocation new ObjectMapper() in the accessDeniedHandler
lambda with a static field (avoids repeated allocation). ObjectMapper
cannot be injected in SecurityConfig because @WebMvcTest slices exclude
JacksonAutoConfiguration; the static instance is safe since the response
only serialises fixed String keys.

Also corrects the ADR cross-reference in the CSRF comment from ADR-020
(Spring Session JDBC) to ADR-022 (CSRF + session revocation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:39:14 +02:00
Marcel
97585a9cd4 test(security): add CSRF rejection test to DocumentControllerTest
Adds regression coverage for the custom accessDeniedHandler in
SecurityConfig: a POST without X-XSRF-TOKEN returns 403 with error
code CSRF_TOKEN_MISSING, not a generic Spring 403.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:33:04 +02:00
Marcel
c32607e133 fix(auth): sequential rate-limit check with ipEmail token refund on IP failure
Addresses Felix (blocker 1): the old implementation consumed from both buckets
before checking either result, silently eroding the per-email quota when only the
per-IP limit was blocking. The fix checks ipEmail first, then IP; on IP failure it
refunds the ipEmail token so legitimate users behind a shared IP are not penalised.

Also adds two new test cases:
- different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion (Sara)
- ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts (red → green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:29:36 +02:00
Marcel
d7eca25eb7 fix(auth): guard revokeOtherSessions/revokeAllSessions against null sessionRepository
Addresses Nora (blocker 1) and Felix (suggestion): both revocation methods
now return 0 immediately when sessionRepository is unavailable (non-web
test contexts where JdbcHttpSessionAutoConfiguration does not fire).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:27:29 +02:00
Marcel
fdb9ae31ae feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m7s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m19s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
  backend API requests (double-submit cookie pattern); generates a fresh
  UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
  getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
  error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
  page shows a muted clock icon with aria-invalid on rate-limit errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
14deae962a feat(auth): add Bucket4j + Caffeine login rate limiter (10/15 min per IP+email, 20/15 min per IP)
LoginRateLimiter uses two Caffeine LoadingCaches of Bucket4j buckets —
one keyed on IP:email (10 attempts/15 min) and one on IP alone (20/15 min
backstop). Exceeding either throws DomainException(TOO_MANY_LOGIN_ATTEMPTS)
and emits LOGIN_RATE_LIMITED audit. Successful login invalidates both
buckets via invalidateOnSuccess. Buckets expire after windowMinutes of
inactivity (no clock advance needed — Caffeine handles eviction).
AuthService integrates it as an optional @Autowired field so non-web
test contexts still work without a Caffeine dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
924c76f99f feat(auth): revoke all sessions on password reset
After updating the user password during a reset flow, calls
authService.revokeAllSessions(email) to invalidate every active session
for the account — prevents an attacker with a stolen session from
retaining access after the owner resets their password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
99a4230bb9 feat(auth): revoke other sessions on password change; add force-logout endpoint
changePassword now calls authService.revokeOtherSessions() after the
password is updated and emits a LOGOUT audit with reason=password_change.

POST /api/users/{id}/force-logout (ADMIN_USER permission) revokes all
sessions for the target user and emits ADMIN_FORCE_LOGOUT audit. Returns
{"revokedCount": N} with 200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
38818998e5 feat(auth): add revokeOtherSessions and revokeAllSessions to AuthService
Uses JdbcIndexedSessionRepository (optional field — null-safe in non-web
test contexts) to delete all sessions for a principal except the current
one (revokeOtherSessions) or all sessions unconditionally (revokeAllSessions).
Both methods return the count of deleted sessions for audit payloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
Marcel
9b4da70f52 feat(security): enable CSRF protection with CookieCsrfTokenRepository
Re-enables Spring Security's CSRF filter (was disabled with a TODO comment).
Uses CookieCsrfTokenRepository so the frontend can read the XSRF-TOKEN
cookie and send it as X-XSRF-TOKEN on state-mutating requests.
Returns CSRF_TOKEN_MISSING error code on 403 instead of generic FORBIDDEN.
Updates all WebMvcTest classes to include .with(csrf()) on POST/PUT/PATCH/
DELETE/multipart requests, and fixes integration tests to supply the
XSRF-TOKEN cookie + header directly (lazy generation in Spring Security 7).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:02:03 +02:00
91 changed files with 2162 additions and 4677 deletions

View File

@@ -13,7 +13,7 @@ jobs:
name: Unit & Component Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.60.0-noble
image: mcr.microsoft.com/playwright:v1.58.2-noble
steps:
- uses: actions/checkout@v4
@@ -29,10 +29,6 @@ jobs:
run: npm ci
working-directory: frontend
- name: Security audit (no dev deps)
run: npm audit --audit-level=high --omit=dev
working-directory: frontend
- name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend

View File

@@ -252,20 +252,20 @@ jobs:
URL="https://$HOST"
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
RESOLVE="--resolve $HOST:443:$HOST_IP"
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"

View File

@@ -181,31 +181,28 @@ jobs:
- name: Smoke test deployed environment
# See nightly.yml — same three checks, against the prod vhost.
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
# separate arguments; a quoted string would pass the flag and its value
# as one token and curl would reject it as an unknown option.
# Gateway detection via /proc/net/route — no iproute2 dependency.
# See nightly.yml for the full network topology explanation.
# --resolve pins to the bridge gateway IP (the host), not 127.0.0.1
# — see nightly.yml for the full network topology explanation.
run: |
set -e
HOST="archiv.raddatz.cloud"
URL="https://$HOST"
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; }
RESOLVE="--resolve $HOST:443:$HOST_IP"
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step.
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"

View File

@@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
```
backend/src/main/java/org/raddatz/familienarchiv/
├── audit/ Audit logging
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
├── config/ Infrastructure config (Minio, Async, Web)
├── dashboard/ Dashboard analytics + StatsController/StatsService
├── document/ Document domain (entities, controller, service, repository, DTOs)
@@ -160,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
### Security / Permissions
@@ -267,7 +267,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
---

View File

@@ -263,7 +263,7 @@ if (!result.response.ok) {
return { person: result.data! }; // non-null assertion is safe after the ok check
```
For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie.
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
### Date handling

View File

@@ -97,10 +97,7 @@ public class MyEntity {
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
- Write methods: `@Transactional`.
- Read methods: no annotation (default non-transactional)**except** when the method returns
an entity whose lazy associations must remain accessible to the caller after the method
returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session
open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022.
- Read methods: no annotation (default non-transactional).
- Cross-domain access goes through the other domain's service, never its repository.
## Error Handling

View File

@@ -7,10 +7,12 @@ import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import org.springframework.stereotype.Service;
import java.util.Map;
@@ -24,17 +26,28 @@ public class AuthService {
private final AuthenticationManager authenticationManager;
private final UserService userService;
private final AuditService auditService;
private final LoginRateLimiter loginRateLimiter;
private final SessionRevocationPort sessionRevocationPort;
@Autowired(required = false)
private JdbcIndexedSessionRepository sessionRepository;
@Autowired(required = false)
private LoginRateLimiter loginRateLimiter;
/**
* Validates credentials and returns the authenticated user plus the Spring Security
* Authentication object. The caller is responsible for persisting the Authentication
* to the session via SecurityContextRepository.
*/
public LoginResult login(String email, String password, String ip, String ua) {
try {
loginRateLimiter.checkAndConsume(ip, email);
} catch (DomainException ex) {
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
"ip", ip,
"email", email));
throw ex;
if (loginRateLimiter != null) {
try {
loginRateLimiter.checkAndConsume(ip, email);
} catch (DomainException ex) {
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
"ip", ip,
"email", email));
throw ex;
}
}
try {
Authentication auth = authenticationManager.authenticate(
@@ -45,7 +58,9 @@ public class AuthService {
"userId", user.getId().toString(),
"ip", ip,
"ua", truncateUa(ua)));
loginRateLimiter.invalidateOnSuccess(ip, email);
if (loginRateLimiter != null) {
loginRateLimiter.invalidateOnSuccess(ip, email);
}
return new LoginResult(user, auth);
} catch (AuthenticationException ex) {
// Audit login failure — intentionally does NOT log the attempted password.
@@ -60,11 +75,22 @@ public class AuthService {
}
public int revokeOtherSessions(String currentSessionId, String principalName) {
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
if (sessionRepository == null) return 0;
int count = 0;
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
if (!id.equals(currentSessionId)) {
sessionRepository.deleteById(id);
count++;
}
}
return count;
}
public int revokeAllSessions(String principalName) {
return sessionRevocationPort.revokeAllSessions(principalName);
if (sessionRepository == null) return 0;
var sessions = sessionRepository.findByPrincipalName(principalName);
sessions.keySet().forEach(sessionRepository::deleteById);
return sessions.size();
}
public void logout(String email, String ip, String ua) {

View File

@@ -1,29 +0,0 @@
package org.raddatz.familienarchiv.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
@RequiredArgsConstructor
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
private final JdbcIndexedSessionRepository sessionRepository;
@Override
public int revokeOtherSessions(String currentSessionId, String principalName) {
int count = 0;
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
if (!id.equals(currentSessionId)) {
sessionRepository.deleteById(id);
count++;
}
}
return count;
}
@Override
public int revokeAllSessions(String principalName) {
var sessions = sessionRepository.findByPrincipalName(principalName);
sessions.keySet().forEach(sessionRepository::deleteById);
return sessions.size();
}
}

View File

@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
@Service
@@ -42,22 +41,20 @@ public class LoginRateLimiter {
// For the current single-VPS setup this is the correct, simplest implementation.
public void checkAndConsume(String ip, String email) {
long retryAfterSeconds = windowMinutes * 60L;
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
if (!byIpEmail.get(key).tryConsume(1)) {
if (!byIpEmail.get(ip + ":" + email).tryConsume(1)) {
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
"Too many login attempts from " + ip, retryAfterSeconds);
"Too many login attempts from " + ip);
}
if (!byIp.get(ip).tryConsume(1)) {
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
byIpEmail.get(key).addTokens(1);
byIpEmail.get(ip + ":" + email).addTokens(1);
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
"Too many login attempts from " + ip, retryAfterSeconds);
"Too many login attempts from " + ip);
}
}
public void invalidateOnSuccess(String ip, String email) {
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
byIpEmail.invalidate(ip + ":" + email);
byIp.invalidate(ip);
}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.auth;
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
@Override
public int revokeOtherSessions(String currentSessionId, String principalName) {
return 0;
}
@Override
public int revokeAllSessions(String principalName) {
return 0;
}
}

View File

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
@Configuration
class SessionRevocationConfig {
@Bean
SessionRevocationPort sessionRevocationPort(
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
if (sessionRepository != null) {
return new JdbcSessionRevocationAdapter(sessionRepository);
}
return new NoOpSessionRevocationAdapter();
}
}

View File

@@ -1,6 +0,0 @@
package org.raddatz.familienarchiv.auth;
public interface SessionRevocationPort {
int revokeOtherSessions(String currentSessionId, String principalName);
int revokeAllSessions(String principalName);
}

View File

@@ -28,7 +28,6 @@ public class RateLimitInterceptor implements HandlerInterceptor {
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "60");
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
return false;
}

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.document;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@@ -22,15 +21,6 @@ import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@NamedEntityGraph(name = "Document.full", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags")
})
@NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("tags")
})
@Entity
@Table(name = "documents")
@Data // Lombok: Generiert Getter, Setter, ToString, etc.
@@ -128,27 +118,24 @@ public class Document {
@Builder.Default
private ScriptType scriptType = ScriptType.UNKNOWN;
@ManyToMany(fetch = FetchType.LAZY)
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Person> receivers = new HashSet<>();
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne
@JoinColumn(name = "sender_id")
private Person sender;
@ManyToMany(fetch = FetchType.LAZY)
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Tag> tags = new HashSet<>();
@ElementCollection(fetch = FetchType.LAZY)
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
@Column(name = "label")
@Enumerated(EnumType.STRING)
@BatchSize(size = 50)
@Builder.Default
private Set<TrainingLabel> trainingLabels = new HashSet<>();

View File

@@ -7,8 +7,6 @@ import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
@@ -25,18 +23,6 @@ import java.util.UUID;
@Repository
public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> {
@EntityGraph("Document.full")
Optional<Document> findById(UUID id);
@EntityGraph("Document.list")
Page<Document> findAll(Specification<Document> spec, Pageable pageable);
@EntityGraph("Document.list")
List<Document> findAll(Specification<Document> spec);
@EntityGraph("Document.list")
Page<Document> findAll(Pageable pageable);
// Findet ein Dokument anhand des ursprünglichen Dateinamens
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename);
@@ -44,21 +30,17 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
Optional<Document> findFirstByOriginalFilename(String originalFilename);
// Callers access only status/id scalar fields — no graph needed.
// Findet alle Dokumente mit einem bestimmten Status
// z.B. um alle offenen "PLACEHOLDER" zu finden
List<Document> findByStatus(DocumentStatus status);
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
boolean existsByOriginalFilename(String originalFilename);
// lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full")
List<Document> findBySenderId(UUID senderId);
// lazy @BatchSize(50) fallback active; see ADR-022
@EntityGraph("Document.full")
List<Document> findByReceiversId(UUID receiverId);
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
List<Document> findByTags_Id(UUID tagId);
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
@@ -73,15 +55,12 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
long countByMetadataCompleteFalse();
// No production callers — only used if a future export path iterates the full list; no graph needed.
List<Document> findByMetadataCompleteFalse(Sort sort);
// Callers map to IncompleteDocumentDTO using only scalar fields (id, title, createdAt) — no graph needed.
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@EntityGraph("Document.full")
@Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " +
"WHERE " +
@@ -96,7 +75,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Param("to") LocalDate to,
Sort sort);
@EntityGraph("Document.full")
@Query("SELECT DISTINCT d FROM Document d " +
"LEFT JOIN d.receivers r " +
"WHERE (d.sender.id = :personId OR r.id = :personId) " +

View File

@@ -447,7 +447,6 @@ public class DocumentService {
return saved;
}
@Transactional
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
@@ -636,7 +635,7 @@ public class DocumentService {
return saved;
}
@Transactional(readOnly = true)
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
public List<Document> getRecentActivity(int size) {
return documentRepository.findAll(
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
@@ -844,7 +843,6 @@ public class DocumentService {
documentRepository.save(doc);
}
@Transactional(readOnly = true)
public Document getDocumentById(UUID id) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));

View File

@@ -10,21 +10,11 @@ public class DomainException extends RuntimeException {
private final ErrorCode code;
private final HttpStatus status;
/** Seconds until the rate-limit window resets; {@code null} when not applicable. */
private final Long retryAfterSeconds;
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
super(developerMessage);
this.code = code;
this.status = status;
this.retryAfterSeconds = null;
}
private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) {
super(developerMessage);
this.code = code;
this.status = status;
this.retryAfterSeconds = retryAfterSeconds;
}
public ErrorCode getCode() {
@@ -35,11 +25,6 @@ public class DomainException extends RuntimeException {
return status;
}
/** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */
public Long getRetryAfterSeconds() {
return retryAfterSeconds;
}
// --- Static factories for common cases ---
public static DomainException notFound(ErrorCode code, String message) {
@@ -74,8 +59,4 @@ public class DomainException extends RuntimeException {
public static DomainException tooManyRequests(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
}
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
}

View File

@@ -23,11 +23,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
var builder = ResponseEntity.status(ex.getStatus());
if (ex.getRetryAfterSeconds() != null) {
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
}
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
return ResponseEntity
.status(ex.getStatus())
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)

View File

@@ -1,8 +1,6 @@
package org.raddatz.familienarchiv.importing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
@@ -33,7 +31,6 @@ import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -56,33 +53,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED }
public record SkippedFile(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
) {}
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
public record ImportStatus(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
@JsonIgnore String message,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
LocalDateTime startedAt
) {
// Note: @Schema on a record accessor method is not picked up by SpringDoc; the
// "skipped" count is a computed convenience field derived from skippedFiles.size().
@JsonProperty("skipped")
public int skipped() { return skippedFiles.size(); }
/** Defensive-copy constructor — callers cannot mutate the stored list after construction. */
public ImportStatus {
skippedFiles = List.copyOf(skippedFiles);
}
}
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
public ImportStatus getStatus() {
return currentStatus;
@@ -144,22 +117,22 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
}
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
try {
File spreadsheet = findSpreadsheetFile();
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
int processed = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
result.processed(), result.skippedFiles(), currentStatus.startedAt());
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
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, List.of(), currentStatus.startedAt());
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
} catch (Exception e) {
log.error("Massenimport fehlgeschlagen", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
}
}
@@ -281,10 +254,8 @@ public class MassImportService {
// --- Import logic (works on neutral List<String> rows) ---
private ProcessResult processRows(List<List<String>> rows) {
int processed = 0;
List<SkippedFile> skippedFiles = new ArrayList<>();
private int processRows(List<List<String>> rows) {
int count = 0;
for (int i = 1; i < rows.size(); i++) { // skip header row
List<String> cells = rows.get(i);
String index = getCell(cells, colIndex);
@@ -295,58 +266,18 @@ public class MassImportService {
if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
}
if (fileOnDisk.isPresent()) {
try {
if (!isPdfMagicBytes(fileOnDisk.get())) {
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
continue;
}
} catch (IOException e) {
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
continue;
}
}
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
if (skipReason.isPresent()) {
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
} else {
processed++;
}
importSingleDocument(cells, fileOnDisk, filename, index);
count++;
}
return new ProcessResult(processed, skippedFiles);
return count;
}
// package-private: Mockito spy in tests can override to inject IOException
InputStream openFileStream(File file) throws IOException {
return new FileInputStream(file);
}
private boolean isPdfMagicBytes(File file) throws IOException {
try (InputStream is = openFileStream(file)) {
byte[] header = is.readNBytes(4);
return header.length == 4
&& header[0] == 0x25 // %
&& header[1] == 0x50 // P
&& header[2] == 0x44 // D
&& header[3] == 0x46; // F
}
}
/**
* Imports a single document row.
*
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
*/
@Transactional
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return Optional.of("ALREADY_EXISTS");
return;
}
String archiveBox = getCell(cells, colBox);
@@ -382,7 +313,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED;
} catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
return Optional.of("S3_UPLOAD_FAILED");
return;
}
}
@@ -424,7 +355,6 @@ public class MassImportService {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
return Optional.empty();
}
// --- Helpers ---

View File

@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.person;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
@@ -10,9 +9,6 @@ import org.raddatz.familienarchiv.user.DisplayNameFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
@Entity
@Table(name = "persons")
@Data

View File

@@ -2,13 +2,10 @@ package org.raddatz.familienarchiv.tag;
import java.util.UUID;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
@Entity
@Data
@NoArgsConstructor

View File

@@ -31,6 +31,5 @@ public class InviteListItemDTO {
private String status;
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String shareableUrl;
}

View File

@@ -30,15 +30,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.AllArgsConstructor;
@RestController
@RequestMapping("/api/")
@RequiredArgsConstructor
@AllArgsConstructor
public class UserController {
private final UserService userService;
private final AuthService authService;
private final AuditService auditService;
private UserService userService;
private AuthService authService;
private AuditService auditService;
@GetMapping("users/me")
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {

View File

@@ -15,10 +15,17 @@ import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import org.raddatz.familienarchiv.exception.ErrorCode;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
@@ -30,13 +37,19 @@ class AuthServiceTest {
@Mock AuthenticationManager authenticationManager;
@Mock UserService userService;
@Mock AuditService auditService;
@Mock JdbcIndexedSessionRepository sessionRepository;
@Mock LoginRateLimiter loginRateLimiter;
@Mock SessionRevocationPort sessionRevocationPort;
@InjectMocks AuthService authService;
private static final String IP = "127.0.0.1";
private static final String UA = "Mozilla/5.0 (Test)";
@BeforeEach
void injectOptionalFields() {
ReflectionTestUtils.setField(authService, "sessionRepository", sessionRepository);
ReflectionTestUtils.setField(authService, "loginRateLimiter", loginRateLimiter);
}
@Test
void login_returns_user_on_valid_credentials() {
UUID userId = UUID.randomUUID();
@@ -146,6 +159,7 @@ class AuthServiceTest {
@Test
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
UUID userId = UUID.randomUUID();
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
@@ -169,23 +183,55 @@ class AuthServiceTest {
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
}
@SuppressWarnings("unchecked")
@Test
void revokeOtherSessions_delegates_to_port() {
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
var sessions = new HashMap<String, Object>();
sessions.put("session-keep", null);
sessions.put("session-del-1", null);
sessions.put("session-del-2", null);
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
verify(sessionRepository, never()).deleteById("session-keep");
verify(sessionRepository).deleteById("session-del-1");
verify(sessionRepository).deleteById("session-del-2");
}
@SuppressWarnings("unchecked")
@Test
void revokeAllSessions_delegates_to_port() {
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
void revokeAllSessions_deletes_all_sessions_for_principal() {
var sessions = new HashMap<String, Object>();
sessions.put("session-1", null);
sessions.put("session-2", null);
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
int count = authService.revokeAllSessions("user@test.de");
assertThat(count).isEqualTo(3);
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRepository).deleteById("session-1");
verify(sessionRepository).deleteById("session-2");
}
// ─── null-guard when sessionRepository is unavailable ────────────────────
@Test
void revokeAllSessions_returns_zero_when_sessionRepository_is_null() {
ReflectionTestUtils.setField(authService, "sessionRepository", null);
int count = authService.revokeAllSessions("user@test.de");
assertThat(count).isEqualTo(0);
}
@Test
void revokeOtherSessions_returns_zero_when_sessionRepository_is_null() {
ReflectionTestUtils.setField(authService, "sessionRepository", null);
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
assertThat(count).isEqualTo(0);
}
}

View File

@@ -119,21 +119,6 @@ class AuthSessionIntegrationTest {
assertThat(me.getStatusCode().value()).isEqualTo(401);
}
// ─── Task: CSRF rejection at integration layer ────────────────────────────
@Test
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
ResponseEntity<String> response = http.postForEntity(
baseUrl + "/api/auth/logout",
new HttpEntity<>("{}", headers), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(403);
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
}
// ─── helpers ─────────────────────────────────────────────────────────────
/**

View File

@@ -1,136 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test for {@link JdbcSessionRevocationAdapter} that verifies
* session rows are actually written to / removed from the {@code spring_session}
* table backed by a real PostgreSQL container.
*
* <p>Sessions are inserted via raw JDBC to avoid the module-access restriction on
* {@code JdbcIndexedSessionRepository.JdbcSession}. The {@link SessionRevocationPort}
* bean injected here is the real {@link JdbcSessionRevocationAdapter} wired by Spring.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JdbcSessionRevocationAdapterIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired SessionRevocationPort adapter;
@Autowired JdbcTemplate jdbcTemplate;
@Autowired TransactionTemplate transactionTemplate;
private static final String PRINCIPAL = "revocation-it@test.de";
@BeforeEach
void clearSessions() {
// spring_session_attributes cascades on delete
transactionTemplate.execute(status -> {
jdbcTemplate.update("DELETE FROM spring_session");
return null;
});
}
// ── helper ─────────────────────────────────────────────────────────────────
/**
* Inserts a minimal {@code spring_session} row attributed to {@value #PRINCIPAL}
* and returns its opaque primary-key ID (the value the repository uses as the
* session identifier, not the {@code SESSION_ID} column which holds the public token).
*
* <p>Column layout mirrors the Flyway-managed schema shipped with the app:
* PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL,
* EXPIRY_TIME, PRINCIPAL_NAME.
*/
/**
* Inserts a persisted session row for {@value #PRINCIPAL} and returns the
* {@code SESSION_ID} column value — this is the opaque identifier that
* {@link JdbcIndexedSessionRepository} uses as the session's public key
* (returned by {@code JdbcSession.getId()} and expected by
* {@link JdbcIndexedSessionRepository#deleteById}).
*
* <p>The inserts run inside a {@link TransactionTemplate} so the rows are
* committed before {@code findByPrincipalName} opens its own transaction and
* can see the data via Read Committed isolation.
*/
private String insertSession() {
String primaryId = UUID.randomUUID().toString();
// SESSION_ID is the value used by JdbcSession.getId() and findByPrincipalName map keys.
String sessionId = UUID.randomUUID().toString();
long now = Instant.now().toEpochMilli();
long expiry = now + 8L * 3600 * 1000; // 8-hour TTL
transactionTemplate.execute(status -> {
jdbcTemplate.update("""
INSERT INTO spring_session
(PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME,
MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
primaryId, sessionId, now, now, 28800, expiry, PRINCIPAL);
// Spring Session's listSessionsByPrincipalName query joins spring_session_attributes;
// insert a minimal attribute row so the session appears in the result set.
jdbcTemplate.update("""
INSERT INTO spring_session_attributes
(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
VALUES (?, ?, ?)
""",
primaryId, "test_attr", new byte[]{0});
return null;
});
return sessionId; // the public key used by JdbcSession.getId() and deleteById()
}
// ── tests ──────────────────────────────────────────────────────────────────
@Test
void revokeAllSessions_removes_every_row_from_spring_session_table() {
insertSession();
insertSession();
int count = adapter.revokeAllSessions(PRINCIPAL);
assertThat(count).isEqualTo(2);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
Long.class, PRINCIPAL))
.isZero();
}
@Test
void revokeOtherSessions_deletes_non_current_rows_and_keeps_current_session() {
String keepId = insertSession();
insertSession();
insertSession();
int count = adapter.revokeOtherSessions(keepId, PRINCIPAL);
assertThat(count).isEqualTo(2);
// The current session row must still be present (keyed by SESSION_ID)
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spring_session WHERE SESSION_ID = ?",
Long.class, keepId))
.isEqualTo(1L);
// The total for this principal is now exactly 1
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
Long.class, PRINCIPAL))
.isEqualTo(1L);
}
}

View File

@@ -1,52 +0,0 @@
package org.raddatz.familienarchiv.auth;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import java.util.HashMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class JdbcSessionRevocationAdapterTest {
@Mock JdbcIndexedSessionRepository sessionRepository;
@InjectMocks JdbcSessionRevocationAdapter adapter;
@SuppressWarnings("unchecked")
@Test
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
var sessions = new HashMap<String, Object>();
sessions.put("session-keep", null);
sessions.put("session-del-1", null);
sessions.put("session-del-2", null);
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRepository, never()).deleteById("session-keep");
verify(sessionRepository).deleteById("session-del-1");
verify(sessionRepository).deleteById("session-del-2");
}
@SuppressWarnings("unchecked")
@Test
void revokeAllSessions_deletes_all_sessions_for_principal() {
var sessions = new HashMap<String, Object>();
sessions.put("session-1", null);
sessions.put("session-2", null);
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
int count = adapter.revokeAllSessions("user@test.de");
assertThat(count).isEqualTo(2);
verify(sessionRepository).deleteById("session-1");
verify(sessionRepository).deleteById("session-2");
}
}

View File

@@ -5,9 +5,8 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThatNoException;
class LoginRateLimiterTest {
@@ -38,22 +37,10 @@ class LoginRateLimiterTest {
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
}
@Test
void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getRetryAfterSeconds())
.isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds
}
@Test
void success_after_10_failures_resets_ip_email_bucket() {
for (int i = 0; i < 10; i++) {
@@ -74,7 +61,7 @@ class LoginRateLimiterTest {
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.satisfies(ex -> org.assertj.core.api.Assertions.assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
}
@@ -91,30 +78,6 @@ class LoginRateLimiterTest {
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
}
@Test
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
}
@Test
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
for (int i = 0; i < 10; i++) {
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
}
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
assertThatNoException().isThrownBy(
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
}
@Test
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
// Use a tighter limiter so the phantom-consumption effect is observable.

View File

@@ -45,15 +45,6 @@ class RateLimitInterceptorTest {
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
}
@Test
void blocked_response_includes_retry_after_header() throws Exception {
for (int i = 0; i < 10; i++) {
interceptor.preHandle(request, response, null);
}
interceptor.preHandle(request, response, null);
verify(response).setHeader("Retry-After", "60");
}
@Test
void different_ips_have_independent_limits() throws Exception {
HttpServletRequest other = mock(HttpServletRequest.class);

View File

@@ -1,178 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dashboard.DashboardService;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Verifies that lazy-loaded associations on {@link Document} are accessible after a service
* method returns — i.e. no {@link org.hibernate.LazyInitializationException} is thrown outside
* the Hibernate session that loaded the entity.
*
* <p><b>Known limitation:</b> calling {@code getDocumentById} (or any other service method) from
* within an already-open transaction is not covered here. When an outer transaction is active,
* the service's own {@code @Transactional} merges into it and Hibernate keeps the same session
* open, so the lazy-init guard behaves differently than in a non-transactional caller. This is a
* known constraint of the test setup, not a bug in the production code.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class DocumentLazyLoadingTest {
@MockitoBean
S3Client s3Client;
@Autowired
DocumentRepository documentRepository;
@Autowired
PersonRepository personRepository;
@Autowired
TagRepository tagRepository;
@Autowired
DocumentService documentService;
@Autowired
DashboardService dashboardService;
@MockitoBean
AuditLogQueryService auditLogQueryService;
@AfterEach
void cleanup() {
documentRepository.deleteAll();
tagRepository.deleteAll();
personRepository.deleteAll();
}
@Test
void getDocumentById_tagsAndReceiversAccessible_afterReturnFromService() {
Person sender = savedPerson("Max", "LzSender");
Person receiver = savedPerson("Anna", "LzReceiver");
Tag tag = savedTag("LzTag");
Document doc = savedDocument("LazyTest", "lazy_test.pdf", sender, Set.of(receiver), Set.of(tag));
Document result = documentService.getDocumentById(doc.getId());
// Only the collection access itself is in assertThatCode — guards against LazyInitializationException.
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
assertThatCode(() -> {
result.getTags().size();
result.getReceivers().size();
}).doesNotThrowAnyException();
assertThat(result.getTags()).isNotEmpty();
result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
assertThat(result.getReceivers()).isNotEmpty();
result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
}
@Test
void getRecentActivity_collectionsAccessibleAfterReturn() {
Person sender = savedPerson("Hans", "RaSender");
Tag tag = savedTag("RaTag");
for (int i = 0; i < 3; i++) {
savedDocument("RaDoc " + i, "ra_doc" + i + ".pdf", sender, Set.of(), Set.of(tag));
}
List<Document> results = documentService.getRecentActivity(3);
// Access lazy fields inside assertThatCode — guards against LazyInitializationException.
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
assertThatCode(() -> {
results.forEach(d -> d.getSender().getLastName());
results.forEach(d -> d.getTags().size());
}).doesNotThrowAnyException();
results.forEach(d -> assertThat(d.getSender()).isNotNull());
results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
results.forEach(d -> assertThat(d.getTags()).isNotEmpty());
}
@Test
void searchDocuments_receiverSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SrSender");
Person receiver = savedPerson("Anna", "SrReceiver");
Tag tag = savedTag("SrTag");
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.RECEIVER, "asc", null,
PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() ->
result.items().forEach(i -> i.document().getSender().getLastName()))
.doesNotThrowAnyException();
}
@Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender");
Tag tag = savedTag("SsTag");
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null,
PageRequest.of(0, 20)))
.doesNotThrowAnyException();
}
@Test
void dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException() {
Person sender = savedPerson("Max", "DsSender");
Person receiver = savedPerson("Anna", "DsReceiver");
Document doc = savedDocument("DashboardTest", "dashboard_test.pdf", sender, Set.of(receiver), Set.of());
UUID fakeUserId = UUID.randomUUID();
when(auditLogQueryService.findMostRecentDocumentForUser(any())).thenReturn(Optional.of(doc.getId()));
when(auditLogQueryService.findRecentContributorsPerDocument(any())).thenReturn(java.util.Map.of());
assertThatCode(() -> dashboardService.getResume(fakeUserId))
.doesNotThrowAnyException();
}
private Person savedPerson(String firstName, String lastName) {
return personRepository.save(Person.builder().firstName(firstName).lastName(lastName).build());
}
private Tag savedTag(String name) {
return tagRepository.save(Tag.builder().name(name).build());
}
private Document savedDocument(String title, String filename, Person sender,
Set<Person> receivers, Set<Tag> tags) {
return documentRepository.save(Document.builder()
.title(title).originalFilename(filename)
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(receivers))
.tags(new HashSet<>(tags))
.build());
}
}

View File

@@ -1,9 +1,5 @@
package org.raddatz.familienarchiv.document;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
@@ -25,7 +21,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -60,12 +55,6 @@ class DocumentRepositoryTest {
@Autowired
private TranscriptionBlockRepository transcriptionBlockRepository;
@Autowired
private EntityManagerFactory entityManagerFactory;
@Autowired
private EntityManager entityManager;
// ─── save and findById ────────────────────────────────────────────────────
@Test
@@ -501,117 +490,6 @@ class DocumentRepositoryTest {
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
}
// ─── query-count — entity-graph assertions ────────────────────────────────
@Test
void findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements() {
Person sender = personRepository.save(Person.builder().firstName("Hans").lastName("QcSender").build());
Person receiver = personRepository.save(Person.builder().firstName("Anna").lastName("QcReceiver").build());
Tag tag = tagRepository.save(Tag.builder().name("QcTag").build());
for (int i = 0; i < 10; i++) {
documentRepository.save(Document.builder()
.title("QcDoc " + i).originalFilename("qcdoc" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver)))
.tags(new HashSet<>(Set.of(tag)))
.build());
}
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
Specification<Document> allDocs = (root, query, cb) -> null;
documentRepository.findAll(allDocs, PageRequest.of(0, 10));
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1")
.isLessThanOrEqualTo(5);
}
@Test
void findById_loadsSenderReceiversAndTagsInAtMostTwoStatements() {
Person sender = personRepository.save(Person.builder().firstName("Max").lastName("FbSender").build());
Set<Person> receivers = new HashSet<>();
for (int i = 0; i < 3; i++) {
receivers.add(personRepository.save(
Person.builder().firstName("R" + i).lastName("FbReceiver").build()));
}
Set<Tag> tags = new HashSet<>();
for (int i = 0; i < 5; i++) {
tags.add(tagRepository.save(Tag.builder().name("FbTag" + i).build()));
}
Document doc = documentRepository.save(Document.builder()
.title("FindByIdQc").originalFilename("findbyid_qc.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(receivers).tags(tags)
.build());
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
documentRepository.findById(doc.getId());
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
.isLessThanOrEqualTo(2);
}
@Test
void findAll_withPageable_loadsSenderWithoutNPlusOne() {
Person sender = personRepository.save(Person.builder().firstName("Maria").lastName("RaSender").build());
Tag tag = tagRepository.save(Tag.builder().name("RaTag2").build());
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("RaDoc2 " + i).originalFilename("radoc2_" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.tags(new HashSet<>(Set.of(tag)))
.build());
}
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
documentRepository.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "updatedAt")));
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.list) via findAll(Pageable) must not N+1 sender for 5 docs")
.isLessThanOrEqualTo(5);
}
@Test
void findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements() {
Person sender = personRepository.save(Person.builder().firstName("Otto").lastName("SoSender").build());
Tag tag = tagRepository.save(Tag.builder().name("SoTag").build());
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("SoDoc " + i).originalFilename("sodoc_" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.tags(new HashSet<>(Set.of(tag)))
.build());
}
entityManager.flush();
entityManager.clear();
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
Specification<Document> allDocs = (root, query, cb) -> null;
documentRepository.findAll(allDocs);
assertThat(stats.getPrepareStatementCount())
.as("@EntityGraph(Document.list) via findAll(Spec) must not N+1 sender for 5 docs")
.isLessThanOrEqualTo(5);
}
// ─── seeding helpers ─────────────────────────────────────────────────────
private Document uploaded(String title) {

View File

@@ -135,7 +135,7 @@ class MassImportServiceTest {
@Test
void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync())
@@ -154,76 +154,9 @@ class MassImportServiceTest {
.build();
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentService, never()).save(any());
assertThat(result).isPresent().contains("ALREADY_EXISTS");
}
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
@Test
void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
// Document already exists with status UPLOADED (not PLACEHOLDER).
// A physical PDF file is also present on disk (valid magic bytes).
// Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
// the guard fires before any file I/O, so no partial processing occurs.
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("present.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));
Path physicalFile = tempDir.resolve("present.pdf");
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(physicalFile, pdfHeader);
Optional<String> result = service.importSingleDocument(
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
assertThat(result).isPresent().contains("ALREADY_EXISTS");
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentService, never()).save(any());
}
// ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
@Test
void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
doThrow(new RuntimeException("S3 unavailable"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
}
@Test
void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
buildMinimalImportXlsx(tempDir, "existing.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("existing.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly("ALREADY_EXISTS");
}
// ─── importSingleDocument — create new document (metadata only) ───────────
@@ -275,7 +208,7 @@ class MassImportServiceTest {
}
@Test
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("fail.pdf");
Files.write(tempFile, "data".getBytes());
@@ -283,11 +216,10 @@ class MassImportServiceTest {
doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
Optional<String> result = service.importSingleDocument(
service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentService, never()).save(any());
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
}
// ─── importSingleDocument — sender handling ───────────────────────────────
@@ -393,8 +325,8 @@ class MassImportServiceTest {
@Test
void processRows_returnsZero_whenOnlyHeaderRow() {
List<List<String>> rows = List.of(List.of("header", "col1"));
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(0);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0);
}
@Test
@@ -403,8 +335,8 @@ class MassImportServiceTest {
List.of("header"),
minimalCells("") // blank index
);
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(0);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0);
verify(documentService, never()).findByOriginalFilename(any());
}
@@ -417,9 +349,9 @@ class MassImportServiceTest {
List.of("header"),
minimalCells("doc001") // no dot → appends ".pdf"
);
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1);
assertThat(result).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc001.pdf");
}
@@ -432,9 +364,9 @@ class MassImportServiceTest {
List.of("header"),
minimalCells("doc002.pdf") // has dot → used as-is
);
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1);
assertThat(result).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc002.pdf");
}
@@ -593,67 +525,6 @@ class MassImportServiceTest {
assertThat(result).isEqualTo("hello");
}
// ─── PDF magic byte validation regression ─────────────────────────────────
@Test
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
}
@Test
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
setupOneValidOneFakeImport(tempDir);
service.runImportAsync();
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename)
.contains("fake.pdf");
}
@Test
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
buildMinimalImportXlsx(tempDir, "tiny.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
}
@Test
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
MassImportService spyService = spy(service);
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
spyService.runImportAsync();
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
assertThat(spyService.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly("FILE_READ_ERROR");
}
// ─── readOds — XXE security regression ───────────────────────────────────
// Security regression — do not remove.
@@ -750,28 +621,4 @@ class MassImportServiceTest {
}
return destination.toFile();
}
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
}
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
Path xlsx = dir.resolve("import.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
sheet.createRow(0).createCell(0).setCellValue("Index");
for (int i = 0; i < filenames.length; i++) {
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
}
try (OutputStream out = Files.newOutputStream(xlsx)) {
wb.write(out);
}
}
}
}

View File

@@ -47,7 +47,7 @@ class AdminControllerTest {
@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, List.of(), null);
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
@@ -61,7 +61,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN")
void importStatus_messageField_notPresentInApiResponse() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))

View File

@@ -20,8 +20,6 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -180,7 +178,7 @@ class UserControllerTest {
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
.andExpect(status().isNoContent());
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
org.mockito.Mockito.verify(authService).revokeOtherSessions(any(), org.mockito.ArgumentMatchers.eq("user@example.com"));
}
@Test
@@ -191,16 +189,6 @@ class UserControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "user@example.com")
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
mockMvc.perform(post("/api/users/me/password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
}
// ─── POST /api/users/{id}/force-logout ────────────────────────────────────
@Test
@@ -242,12 +230,4 @@ class UserControllerTest {
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
}
}

View File

@@ -276,9 +276,6 @@ services:
# SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN}
API_INTERNAL_URL: http://backend:8080
ORIGIN: https://${APP_DOMAIN}
# Enforce upload size limit in the adapter-node layer (fixes GHSA-2crg-3p73-43xp bypass).
# Must be ≤ client_max_body_size in the Caddy reverse proxy to avoid 413 mismatches.
BODY_SIZE_LIMIT: 50M
networks:
- archiv-net
healthcheck:

View File

@@ -228,9 +228,6 @@ services:
API_INTERNAL_URL: http://backend:8080
# Vite dev proxy forwards /api from browser to the backend container
API_PROXY_TARGET: http://backend:8080
# Upload size limit for adapter-node (production target). Not enforced by Vite dev server
# but kept here to match docker-compose.prod.yml and prevent config drift.
BODY_SIZE_LIMIT: 50M
ports:
- "${PORT_FRONTEND}:5173"
networks:

View File

@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
@@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another
### Permission system
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference.
Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
---

View File

@@ -57,10 +57,6 @@ _See also [Annotation](#annotation-documentannotation)._
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
**SkippedFile** (`MassImportService.SkippedFile`) — a file that was presented for import but not processed, recorded with a `filename` and a `reason` code. Possible reasons: `INVALID_PDF_SIGNATURE` (magic-byte validation failed), `S3_UPLOAD_FAILED` (file upload to MinIO/S3 threw an exception), `FILE_READ_ERROR` (the file could not be opened for reading), or `ALREADY_EXISTS` (a document with the same filename already exists in the archive with a status other than `PLACEHOLDER`).
**skipped count** — the total number of `SkippedFile` entries accumulated during a single import run (`ImportStatus.skipped()`). Shown in the amber warning section of the Import Status Card in the admin UI; a value of zero suppresses the section entirely.
**Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists.
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._

View File

@@ -104,12 +104,3 @@ source.
because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response
only serialises a fixed String key (`"code"`) so naming strategy and custom
modules are irrelevant.
- IP extraction uses `HttpServletRequest.getRemoteAddr()`. In deployments behind
a reverse proxy the `X-Forwarded-For` header is not trusted — doing so would
let clients spoof their IP and trivially bypass the per-IP limit. Trusting
proxy headers requires separate work (e.g. Spring's `ForwardedHeaderFilter`
with an allowlist of trusted proxy addresses).
- IPv6 and IPv4-mapped addresses (e.g. `::ffff:1.2.3.4`) are not normalised to
a canonical form. An attacker with access to multiple IPv6 addresses could
rotate addresses to bypass the per-IP bucket. This is a known limitation of
address-based rate limiting and is acceptable for the current deployment.

View File

@@ -1,110 +0,0 @@
# ADR-022 — EAGER→LAZY Fetch Strategy for Document Collections
**Date:** 2026-05-18
**Status:** Accepted
**Issue:** #467
**PR:** #622
---
## Context
A pre-production query audit of 24 HTTP requests to the document list and detail endpoints
produced **2,733 SQL statements** — primarily N+1 queries caused by `FetchType.EAGER` on
`Document.receivers`, `Document.tags`, `Document.trainingLabels`, and `Document.sender`.
With EAGER fetch, every `Document` loaded by any repository method immediately triggers
additional `SELECT` statements for each associated collection, regardless of whether the
caller needs those associations. For a list of 100 documents, this means up to 400 extra
queries for `receivers` alone.
---
## Decision
Switch all four associations to `FetchType.LAZY` and use a two-tier strategy to load exactly
what each code path needs:
**Tier 1 — Named entity graphs on `Document` + `@EntityGraph` overrides on `DocumentRepository`:**
- `Document.full` — loads `sender`, `receivers`, `tags` — used by `findById` (detail view)
- `Document.list` — loads `sender`, `tags` — used by `findAll(Spec, Pageable)`,
`findAll(Spec)`, and `findAll(Pageable)` (list/search/dashboard paths)
Each repository method that is called from a hot code path has an `@EntityGraph` override
that declares exactly which associations to JOIN-fetch, collapsing N+1 into 12 queries.
**Tier 2 — `@BatchSize(50)` fallback on all four associations:**
For any lazy access path not covered by an entity graph (e.g., a future ad-hoc query or an
in-memory sort that touches `trainingLabels`), Hibernate batches the secondary `SELECT` to
at most one statement per 50 entities instead of one per entity.
**Session lifetime for post-return lazy access:**
`getDocumentById` and `getRecentActivity` return entities to callers that may access lazy
associations after the repository call returns. Both methods are annotated
`@Transactional(readOnly = true)` to keep the Hibernate session open until the service method
returns, making those post-return accesses safe.
This is an intentional exception to the project convention that read methods are not annotated
(see `CLAUDE.md §Services`). The convention remains correct for all other read methods; this
exception applies only to methods that serve lazy-initialized associations to their callers.
---
## Alternatives Considered
### `@BatchSize`-only (no entity graphs)
`@BatchSize(50)` on all associations would eliminate the worst N+1 cases (100 documents → 2
batch queries instead of 100 individual queries) without requiring repository overrides. Simpler
to maintain — no named graph definitions, no per-method overrides.
Rejected because batch loading is best-effort: it depends on what Hibernate happens to find in
the first-level cache and produces a variable number of statements. Entity graphs produce a
deterministic, verifiable statement count that can be asserted in tests. The query-count test
suite (`DocumentRepositoryTest`) validates the exact statement bounds on every CI run.
### Single unified entity graph (`Document.full` everywhere)
Loading `receivers` on every list query is wasteful — the document list view only needs
`sender` and `tags`. `receivers` is a `@ManyToMany` collection that, when JOIN-fetched together
with `tags`, forces Hibernate to split into two queries anyway (to avoid Cartesian product).
Using a single graph on list paths would load data the UI does not display.
Rejected in favour of two graphs with distinct scopes: `Document.list` for list paths
(sender + tags), `Document.full` for detail paths (sender + receivers + tags).
### `@Transactional` on the Spring Data repository methods
Spring Data allows `@Transactional` on repository interfaces directly. This would keep the
session open for all calls to those methods without touching the service layer.
Rejected because the transaction boundary belongs at the service layer — repositories should
not own transaction lifecycle. The service methods are the natural scope for "keep the session
open long enough for the caller to use the result."
---
## Consequences
- **Query count reduced from ~2,733 to ≤10 statements per 24 HTTP requests** — verified by
`DocumentRepositoryTest` query-count assertions and `DocumentLazyLoadingTest` smoke tests.
- **Read methods that return lazily-initialized entities must carry `@Transactional(readOnly = true)`.**
Any future service method that loads a `Document` and returns it to a caller that accesses
lazy associations must follow this pattern. Removing the annotation causes
`LazyInitializationException` in production.
- **New lazy code paths need an entity graph or `@BatchSize` review.** Any new
`DocumentRepository` method added to a hot code path should be assessed for N+1 risk and
given an `@EntityGraph` override if warranted.
- **`@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` required on serialized lazy-proxy entities.**
`Person` and `Tag` carry this annotation to prevent Jackson from attempting to serialize
Hibernate proxy internals when the association is not initialized. Any new entity that is
used as a lazy association and serialized directly (without a DTO) needs the same annotation.
- **Named graph strings in `Document.java` and `DocumentRepository.java` must stay in sync.**
The `@NamedEntityGraph(name = "Document.full")` / `@NamedEntityGraph(name = "Document.list")`
definitions on `Document` are referenced by string in every `@EntityGraph(value = "...")` on
`DocumentRepository`. If the names diverge (e.g. a graph is renamed in one place but not the
other), Spring Data throws at application startup. Always update both files together when
renaming or restructuring a named graph.

View File

@@ -9,23 +9,18 @@ ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.")
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.")
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.")
Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.")
}
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header")
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
Rel(authCtrl, authSvc, "Validate creds + audit")
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()")
Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()")
Rel(rateLimiter, rateLimitProps, "Reads config")
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
Rel(secConf, userDetails, "Wires as UserDetailsService")

View File

@@ -58,20 +58,3 @@ test.describe('Language selector', () => {
await expect(deBtn).toHaveClass(/font-bold/);
});
});
test.describe('Mobile nav — i18n', () => {
test('hamburger button aria-label translates to EN on narrow viewport', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 375, height: 812 },
storageState: 'e2e/.auth/user.json'
});
const page = await context.newPage();
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible();
await context.close();
});
});

View File

@@ -28,8 +28,6 @@
"nav_conversations": "Briefwechsel",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"layout_menu_open": "Menü öffnen",
"layout_menu_close": "Menü schließen",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern",
@@ -354,11 +352,6 @@
"admin_system_import_status_running": "Import läuft…",
"admin_system_import_status_done": "Import abgeschlossen",
"admin_system_import_status_done_label": "Dokumente verarbeitet",
"admin_system_import_skipped_label": "übersprungen",
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
"import_reason_s3_upload_failed": "Upload-Fehler (S3)",
"import_reason_already_exists": "Bereits importiert",
"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.",
@@ -396,10 +389,6 @@
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen",
"viewer_previous_page": "Zurück",
"viewer_next_page": "Weiter",
"viewer_zoom_out": "Verkleinern",
"viewer_zoom_in": "Vergrößern",
"upload_action": "Hochladen",
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
@@ -522,7 +511,6 @@
"notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort",
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen",
@@ -662,7 +650,6 @@
"transcription_block_segmentation_only": "Nur Segmentierung",
"training_chip_kurrent": "Kurrent-Erkennung",
"training_chip_segmentation": "Segmentierung",
"transcribe_mark_for_training": "Für Training vormerken",
"training_col_type": "Typ",
"training_type_base": "Basis",
"training_type_personalized": "Personalisiert",

View File

@@ -28,8 +28,6 @@
"nav_conversations": "Letters",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"layout_menu_open": "Open menu",
"layout_menu_close": "Close menu",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save",
@@ -354,11 +352,6 @@
"admin_system_import_status_running": "Import running…",
"admin_system_import_status_done": "Import complete",
"admin_system_import_status_done_label": "Documents processed",
"admin_system_import_skipped_label": "skipped",
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
"import_reason_file_read_error": "File read error",
"import_reason_s3_upload_failed": "Upload error (S3)",
"import_reason_already_exists": "Already imported",
"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.",
@@ -396,10 +389,6 @@
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide annotations",
"viewer_previous_page": "Previous page",
"viewer_next_page": "Next page",
"viewer_zoom_out": "Zoom out",
"viewer_zoom_in": "Zoom in",
"upload_action": "Upload",
"upload_drop_hint": "Drop one or multiple files at once",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
@@ -522,7 +511,6 @@
"notification_filter_unread": "Unread",
"notification_filter_mention": "Mention",
"notification_filter_reply": "Reply",
"notification_error_generic": "Action failed. Please try again.",
"notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older",
"notification_empty_history": "No notifications",
@@ -662,7 +650,6 @@
"transcription_block_segmentation_only": "Segmentation only",
"training_chip_kurrent": "Kurrent recognition",
"training_chip_segmentation": "Segmentation",
"transcribe_mark_for_training": "Mark for OCR training",
"training_col_type": "Type",
"training_type_base": "Base",
"training_type_personalized": "Personalized",

View File

@@ -28,8 +28,6 @@
"nav_conversations": "Cartas",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"layout_menu_open": "Abrir menú",
"layout_menu_close": "Cerrar menú",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar",
@@ -354,11 +352,6 @@
"admin_system_import_status_running": "Importación en curso…",
"admin_system_import_status_done": "Importación completada",
"admin_system_import_status_done_label": "Documentos procesados",
"admin_system_import_skipped_label": "omitidos",
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
"import_reason_file_read_error": "Error al leer el archivo",
"import_reason_s3_upload_failed": "Error de carga (S3)",
"import_reason_already_exists": "Ya importado",
"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.",
@@ -396,10 +389,6 @@
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar anotaciones",
"viewer_previous_page": "Página anterior",
"viewer_next_page": "Página siguiente",
"viewer_zoom_out": "Reducir",
"viewer_zoom_in": "Ampliar",
"upload_action": "Subir",
"upload_drop_hint": "Uno o varios archivos a la vez",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
@@ -522,7 +511,6 @@
"notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta",
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones",
@@ -662,7 +650,6 @@
"transcription_block_segmentation_only": "Solo segmentación",
"training_chip_kurrent": "Reconocimiento Kurrent",
"training_chip_segmentation": "Segmentación",
"transcribe_mark_for_training": "Marcar para entrenamiento de OCR",
"training_col_type": "Tipo",
"training_type_base": "Base",
"training_type_personalized": "Personalizado",

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
"test:unit": "vitest",
"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:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
@@ -24,9 +24,9 @@
},
"dependencies": {
"@sentry/sveltekit": "^10.53.1",
"@tiptap/core": "3.23.4",
"@tiptap/extension-mention": "3.23.4",
"@tiptap/starter-kit": "3.23.4",
"@tiptap/core": "3.22.5",
"@tiptap/extension-mention": "3.22.5",
"@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3",
"isomorphic-dompurify": "^3.12.0",
"openapi-fetch": "^0.13.5",
@@ -37,9 +37,9 @@
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.60.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
@@ -57,7 +57,7 @@
"globals": "^16.5.0",
"openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0",
"playwright": "^1.60.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
@@ -66,7 +66,7 @@
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.3.3",
"vite": "^7.2.2",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"

View File

@@ -1,30 +1,30 @@
diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js
index c01e754..f1bb7be 100644
index 5d0d37b..821d7b4 100644
--- a/node_modules/@vitest/browser-playwright/dist/index.js
+++ b/node_modules/@vitest/browser-playwright/dist/index.js
@@ -936,7 +936,7 @@ class PlaywrightBrowserProvider {
@@ -935,7 +935,7 @@ class PlaywrightBrowserProvider {
createMocker() {
const idPredicates = new Map();
const idPreficates = new Map();
const sessionIds = new Map();
- function createPredicate(sessionId, url) {
+ function createPredicate(url) {
const moduleUrl = new URL(url, "http://localhost");
const predicate = (url) => {
if (url.searchParams.has("_vitest_original")) {
@@ -961,11 +961,7 @@ class PlaywrightBrowserProvider {
@@ -960,11 +960,7 @@ class PlaywrightBrowserProvider {
}
return true;
};
- const ids = sessionIds.get(sessionId) || [];
- ids.push(moduleUrl.href);
- sessionIds.set(sessionId, ids);
- idPredicates.set(predicateKey(sessionId, moduleUrl.href), predicate);
- idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate);
- return predicate;
+ return { url: moduleUrl.href, predicate };
}
function predicateKey(sessionId, url) {
return `${sessionId}:${url}`;
@@ -973,7 +969,23 @@ class PlaywrightBrowserProvider {
@@ -972,7 +968,23 @@ class PlaywrightBrowserProvider {
return {
register: async (sessionId, module) => {
const page = this.getPage(sessionId);
@@ -37,19 +37,19 @@ index c01e754..f1bb7be 100644
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
+ // leak an orphan route whose handler crashes after the next
+ // session's birpc channel closes.
+ const existingPredicate = idPredicates.get(key);
+ const existingPredicate = idPreficates.get(key);
+ if (existingPredicate) {
+ await page.context().unroute(existingPredicate);
+ }
+ const ids = sessionIds.get(sessionId) ?? new Set();
+ ids.add(moduleUrl);
+ sessionIds.set(sessionId, ids);
+ idPredicates.set(key, predicate);
+ idPreficates.set(key, predicate);
+ await page.context().route(predicate, async (route) => {
if (module.type === "manual") {
const exports$1 = Object.keys(await module.resolve());
const body = createManualModuleSource(module.url, exports$1);
@@ -1034,8 +1046,8 @@ class PlaywrightBrowserProvider {
@@ -1033,8 +1045,8 @@ class PlaywrightBrowserProvider {
},
clear: async (sessionId) => {
const page = this.getPage(sessionId);
@@ -58,5 +58,5 @@ index c01e754..f1bb7be 100644
+ const ids = sessionIds.get(sessionId) ?? new Set();
+ const promises = [...ids].map((id) => {
const key = predicateKey(sessionId, id);
const predicate = idPredicates.get(key);
const predicate = idPreficates.get(key);
if (predicate) {

View File

@@ -111,7 +111,7 @@ const PUBLIC_API_PATHS = [
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const isApi = request.url.startsWith(apiUrl) || new URL(request.url).pathname.startsWith('/api/');
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
if (!isApi) return fetch(request);
@@ -131,13 +131,14 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
if (sessionId) cookieParts.push(`fa_session=${sessionId}`);
if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`);
if (cookieParts.length === 0) {
if (cookieParts.length === 0 && !xsrfToken) {
return fetch(request);
}
// Clone first so the body stream is preserved on the new Request.
const cloned = request.clone();
const extraHeaders: Record<string, string> = { Cookie: cookieParts.join('; ') };
const extraHeaders: Record<string, string> = {};
if (cookieParts.length > 0) extraHeaders['Cookie'] = cookieParts.join('; ');
if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken;
const modified = new Request(cloned, {

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { enhance } from '$app/forms';
import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
@@ -7,13 +6,11 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
interface Props {
unread: NotificationItem[];
optimisticMarkRead: (id: string) => void;
optimisticMarkAllRead: () => void;
onMarkRead: (n: NotificationItem) => void;
onMarkAllRead: () => void;
}
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
let errorMessage: string | null = $state(null);
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
function verb(type: NotificationItem['type'], actor: string): string {
return type === 'REPLY'
@@ -27,9 +24,6 @@ function href(n: NotificationItem): string {
</script>
<section class="rounded-sm border border-line bg-surface p-5">
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
{#if unread.length === 0}
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
<svg
@@ -72,28 +66,14 @@ function href(n: NotificationItem): string {
{m.chronik_for_you_count({ count: unread.length })}
</span>
</div>
<form
action="/aktivitaeten?/mark-all-read"
method="POST"
use:enhance={() => {
errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
<button
type="button"
data-testid="chronik-mark-all-read"
onclick={onMarkAllRead}
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
<button
type="submit"
data-testid="chronik-mark-all-read"
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.chronik_mark_all_read()}
</button>
</form>
{m.chronik_mark_all_read()}
</button>
</div>
<ul role="list" class="flex flex-col gap-2">
@@ -109,7 +89,7 @@ function href(n: NotificationItem): string {
aria-hidden="true"
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
>
{n.type === 'MENTION' ? '@' : ''}
{n.type === 'MENTION' ? '@' : '\u21A9'}
</span>
<div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink">
@@ -120,40 +100,25 @@ function href(n: NotificationItem): string {
</p>
</div>
</a>
<form
action="/aktivitaeten?/dismiss-notification"
method="POST"
use:enhance={() => {
errorMessage = null;
optimisticMarkRead(n.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
<button
type="button"
data-testid="chronik-fuerdich-dismiss"
aria-label={m.chronik_mark_read_aria()}
onclick={() => onMarkRead(n)}
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<input type="hidden" name="notificationId" value={n.id} />
<button
type="submit"
data-testid="chronik-fuerdich-dismiss"
aria-label={m.chronik_mark_read_aria()}
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>

View File

@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
afterEach(cleanup);
function notif(partial: Partial<NotificationItem>): NotificationItem {
return {
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, {
unread: [],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull();
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, {
unread: [],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull();
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
});
@@ -85,8 +56,8 @@ describe('ChronikFuerDichBox', () => {
it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
// Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
@@ -98,8 +69,8 @@ describe('ChronikFuerDichBox', () => {
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, {
unread: [],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
@@ -109,38 +80,38 @@ describe('ChronikFuerDichBox', () => {
it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
});
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead
onMarkRead: vi.fn(),
onMarkAllRead
});
await userEvent.click(page.getByText('Alle gelesen'));
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1);
expect(onMarkAllRead).toHaveBeenCalledTimes(1);
});
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
const optimisticMarkRead = vi.fn();
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, {
unread: [n],
optimisticMarkRead,
optimisticMarkAllRead: vi.fn()
onMarkRead,
onMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
});
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9'
})
],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
@@ -165,8 +136,8 @@ describe('ChronikFuerDichBox', () => {
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull();
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull();
});
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
});

View File

@@ -4,36 +4,7 @@ import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
afterEach(cleanup);
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1',
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, {
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
});
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ actorName: 'Bertha' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible();
});
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn();
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
const onMarkRead = vi.fn();
const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, {
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
});
const dismiss = document.querySelector(
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement;
dismiss.click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
expect(onMarkRead).toHaveBeenCalledWith(item);
});
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
const optimisticMarkAllRead = vi.fn();
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, {
props: {
unread: [mention()],
optimisticMarkRead: () => {},
optimisticMarkAllRead
onMarkRead: () => {},
onMarkAllRead
}
});
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toContain('doc-x');
});
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
}
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLElement;
dismiss.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
});

View File

@@ -303,9 +303,7 @@ async function handleLabelToggle(label: string) {
{#if canWrite && hasBlocks}
<div class="border-t border-line px-4 py-3">
<p class="mb-2 font-sans text-xs font-medium text-ink-2">
{m.transcribe_mark_for_training()}
</p>
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
<div class="flex flex-wrap gap-2">
{#each [{ label: 'KURRENT_RECOGNITION', display: m.training_chip_kurrent() }, { label: 'KURRENT_SEGMENTATION', display: m.training_chip_segmentation() }] as chip (chip.label)}
<button

View File

@@ -34,7 +34,7 @@ let {
<button
onclick={onPrev}
disabled={currentPage <= 1}
aria-label={m.viewer_previous_page()}
aria-label="Zurück"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -51,7 +51,7 @@ let {
<button
onclick={onNext}
disabled={!isLoaded || currentPage >= totalPages}
aria-label={m.viewer_next_page()}
aria-label="Weiter"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -64,7 +64,7 @@ let {
<div class="flex items-center gap-1">
<button
onclick={onZoomOut}
aria-label={m.viewer_zoom_out()}
aria-label="Verkleinern"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -74,7 +74,7 @@ let {
</button>
<button
onclick={onZoomIn}
aria-label={m.viewer_zoom_in()}
aria-label="Vergrößern"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">

View File

@@ -2,7 +2,6 @@ import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import PdfControls from './PdfControls.svelte';
afterEach(cleanup);
@@ -24,28 +23,28 @@ describe('PdfControls — annotation toggle visibility', () => {
it('renders annotation toggle when annotationCount is greater than zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 3 });
await expect
.element(page.getByRole('button', { name: m.pdf_annotations_show() }))
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
.toBeInTheDocument();
});
it('does not render annotation toggle when annotationCount is zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 0 });
await expect
.element(page.getByRole('button', { name: m.pdf_annotations_show() }))
.element(page.getByRole('button', { name: /annotierungen/i }))
.not.toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle label', () => {
it('shows show-annotations label when annotations are hidden', async () => {
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
const btn = page.getByRole('button', { name: m.pdf_annotations_show() });
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
await expect.element(btn).toBeInTheDocument();
});
it('shows hide-annotations label when annotations are visible', async () => {
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
const btn = page.getByRole('button', { name: m.pdf_annotations_hide() });
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
await expect.element(btn).toBeInTheDocument();
});
});
@@ -59,9 +58,7 @@ describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
b.getAttribute('aria-label') ?? ''
)
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('text-primary');
@@ -78,9 +75,7 @@ describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
b.getAttribute('aria-label') ?? ''
)
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('focus-visible:ring-2');
@@ -91,12 +86,7 @@ describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return [
m.viewer_previous_page(),
m.viewer_next_page(),
m.viewer_zoom_out(),
m.viewer_zoom_in()
].includes(label);
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
@@ -114,9 +104,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
b.getAttribute('aria-label') ?? ''
)
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-h-[44px]');
@@ -130,9 +118,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
b.getAttribute('aria-label') ?? ''
)
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-w-[44px]');
@@ -145,9 +131,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
showAnnotations: false
});
const btn1 = Array.from(c1.querySelectorAll('button')).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
b.getAttribute('aria-label') ?? ''
)
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(btn1!.getAttribute('aria-pressed')).toBe('false');
cleanup();
@@ -158,9 +142,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
showAnnotations: true
});
const btn2 = Array.from(c2.querySelectorAll('button')).find((b) =>
[m.pdf_annotations_show(), m.pdf_annotations_hide()].includes(
b.getAttribute('aria-label') ?? ''
)
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(btn2!.getAttribute('aria-pressed')).toBe('true');
});
@@ -170,12 +152,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return [
m.viewer_previous_page(),
m.viewer_next_page(),
m.viewer_zoom_out(),
m.viewer_zoom_in()
].includes(label);
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
@@ -188,12 +165,7 @@ describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return [
m.viewer_previous_page(),
m.viewer_next_page(),
m.viewer_zoom_out(),
m.viewer_zoom_in()
].includes(label);
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {

View File

@@ -180,22 +180,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/{id}/force-logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["forceLogout"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me/password": {
parameters: {
query?: never;
@@ -596,38 +580,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["logout"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/forgot-password": {
parameters: {
query?: never;
@@ -1897,7 +1849,7 @@ export interface components {
status: string;
/** Format: date-time */
createdAt: string;
shareableUrl: string;
shareableUrl?: string;
};
GroupDTO: {
name?: string;
@@ -2059,17 +2011,13 @@ export interface components {
lastName?: string;
notifyOnMention?: boolean;
};
LoginRequest: {
email?: string;
password?: string;
};
ForgotPasswordRequest: {
email?: string;
};
ImportStatus: {
/** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode?: string;
message?: string;
/** Format: int32 */
processed?: number;
/** Format: date-time */
@@ -2307,14 +2255,14 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
/** Format: int32 */
number?: number;
sort?: components["schemas"]["SortObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
numberOfElements?: number;
empty?: boolean;
@@ -2462,7 +2410,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -3006,30 +2954,6 @@ export interface operations {
};
};
};
forceLogout: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: unknown;
};
};
};
};
};
changePassword: {
parameters: {
query?: never;
@@ -3623,7 +3547,6 @@ export interface operations {
query?: never;
header?: never;
path: {
documentId: string;
blockId: string;
};
cookie?: never;
@@ -3674,7 +3597,6 @@ export interface operations {
header?: never;
path: {
documentId: string;
blockId: string;
commentId: string;
};
cookie?: never;
@@ -3869,48 +3791,6 @@ export interface operations {
};
};
};
logout: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
forgotPassword: {
parameters: {
query?: never;
@@ -5105,7 +4985,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentDensityResult"];
"*/*": components["schemas"]["DocumentDensityResult"];
};
};
};
@@ -5181,7 +5061,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
};
header?: never;
path?: never;

View File

@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest';
import de from '../../messages/de.json';
import en from '../../messages/en.json';
import es from '../../messages/es.json';
describe('message key parity', () => {
it('de, en, and es have identical key sets', () => {
const deKeys = Object.keys(de).sort();
const enKeys = Object.keys(en).sort();
const esKeys = Object.keys(es).sort();
expect(enKeys).toEqual(deKeys);
expect(esKeys).toEqual(deKeys);
});
it('viewer navigation keys are present in all locales', () => {
const requiredViewerKeys = [
'viewer_previous_page',
'viewer_next_page',
'viewer_zoom_out',
'viewer_zoom_in'
];
for (const key of requiredViewerKeys) {
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
}
});
it('transcribe mark-for-training key is present in all locales', () => {
expect(de).toHaveProperty('transcribe_mark_for_training');
expect(en).toHaveProperty('transcribe_mark_for_training');
expect(es).toHaveProperty('transcribe_mark_for_training');
});
it('layout menu open/close keys are present in all locales', () => {
expect(de).toHaveProperty('layout_menu_open');
expect(de).toHaveProperty('layout_menu_close');
expect(en).toHaveProperty('layout_menu_open');
expect(en).toHaveProperty('layout_menu_close');
expect(es).toHaveProperty('layout_menu_open');
expect(es).toHaveProperty('layout_menu_close');
});
});

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false);
@@ -28,6 +30,17 @@ function closeDropdown() {
bellButtonEl?.focus();
}
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
);
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
@@ -100,8 +113,8 @@ onDestroy(() => {
{#if open}
<NotificationDropdown
notifications={stream.notifications}
optimisticMarkRead={stream.optimisticMarkRead}
optimisticMarkAllRead={stream.optimisticMarkAllRead}
onMarkRead={handleMarkRead}
onMarkAllRead={stream.markAllRead}
onClose={closeDropdown}
/>
{/if}

View File

@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() {
return mockNotificationList.value.length;
},
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn(),
markRead: mockMarkRead,
fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(),
destroy: vi.fn()
destroy: vi.fn(),
markAllRead: vi.fn()
}
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = [];
});
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides
});
async function openDropdownAndClickFirstNotification() {
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith(
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
);
});
});
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
mockNotificationList.value = [makeNotification({ annotationId: null })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
});
});
});

View File

@@ -1,21 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = {
notifications: NotificationItem[];
optimisticMarkRead: (id: string) => void;
optimisticMarkAllRead: () => void;
onMarkRead: (notification: NotificationItem) => void;
onMarkAllRead: () => void;
onClose: () => void;
};
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition
@@ -35,35 +31,16 @@ function handleViewAll() {
{m.notification_bell_label()}
</span>
{#if notifications.length > 0}
<form
action="/aktivitaeten?/mark-all-read"
method="POST"
use:enhance={() => {
errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
<button
type="button"
onclick={onMarkAllRead}
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
<button
type="submit"
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{m.notification_mark_all_read()}
</button>
{/if}
</div>
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
<!-- Notification list -->
{#if notifications.length === 0}
<!-- Empty state -->
@@ -89,93 +66,67 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)}
<li>
<form
action="/aktivitaeten?/dismiss-notification"
method="POST"
class="contents"
use:enhance={() => {
errorMessage = null;
optimisticMarkRead(notification.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
} else {
// Navigate away — no need to update the store since we're leaving the page
onClose();
goto(
buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
)
);
}
};
}}
<button
type="button"
onclick={() => onMarkRead(notification)}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<input type="hidden" name="notificationId" value={notification.id} />
<button
type="submit"
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</button>
</form>
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li>
{/each}
</ul>

View File

@@ -6,38 +6,9 @@ import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
});
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true })
],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1);
});
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const input = document.querySelector<HTMLInputElement>(
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
);
expect(input?.value).toBe('n42');
});
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
const optimisticMarkRead = vi.fn();
it('calls onMarkRead with the notification when an item is clicked', async () => {
const onMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead,
optimisticMarkAllRead: () => {},
onMarkRead,
onMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
expect(onMarkRead).toHaveBeenCalledWith(n);
});
it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead,
onMarkRead: () => {},
onMarkAllRead,
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
mockFormResult.type = 'failure';
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('calls onClose when the view-all button is clicked', async () => {
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => {
callOrder.push('goto');
return Promise.resolve();
});
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' })
],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
expect(forms.length).toBe(2);
});
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
const onClose = vi.fn();
const n = makeNotification({
id: 'n42',
documentId: 'd1',
referenceId: 'c1',
annotationId: null,
actorName: 'Anna'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
expect(onClose).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
const n = makeNotification({
id: 'n55',
documentId: 'd1',
referenceId: 'c1',
annotationId: 'a1',
actorName: 'Eva'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
const items = document.querySelectorAll('button[type="button"]');
// At least 2 items + mark-all button
expect(items.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1);
});
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
it('markAllRead resets unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
await notificationStore.markAllRead();
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.notifications[0].read).toBe(true);
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
});
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
);
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n2', read: false }))
);
mockFetch.mockReset();
notificationStore.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -35,19 +35,28 @@ async function fetchUnreadCount(): Promise<void> {
}
}
function optimisticMarkRead(id: string): void {
const notification = notifications.find((n) => n.id === id);
if (notification && !notification.read) {
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
async function markRead(notification: NotificationItem): Promise<void> {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
}
function optimisticMarkAllRead(): void {
for (const n of notifications) {
n.read = true;
async function markAllRead(): Promise<void> {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
unreadCount = 0;
}
function init(): void {
@@ -114,8 +123,8 @@ export const notificationStore = {
},
fetchNotifications,
fetchUnreadCount,
optimisticMarkRead,
optimisticMarkAllRead,
markRead,
markAllRead,
init,
destroy
};

View File

@@ -94,7 +94,7 @@ function handleOverlayKeydown(event: KeyboardEvent) {
<!-- Hamburger toggle (mobile only) -->
<button
class="ml-auto flex h-11 w-11 items-center justify-center self-center rounded text-white/70 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring lg:hidden"
aria-label={mobileNavOpen ? m.layout_menu_close() : m.layout_menu_open()}
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileNavOpen}
aria-controls="mobile-nav"
onclick={() => (mobileNavOpen = !mobileNavOpen)}

View File

@@ -19,22 +19,14 @@ describe('admin/groups layout load', () => {
{ id: 'g1', name: 'Admins', permissions: ['ADMIN'] },
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
]);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/groups'),
url: new URL('http://localhost/admin/groups')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.groups).toHaveLength(2);
expect(result.groups[0].name).toBe('Admins');
});
it('returns an empty array when the API returns nothing', async () => {
mockApi([]);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/groups'),
url: new URL('http://localhost/admin/groups')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.groups).toEqual([]);
});
@@ -43,11 +35,7 @@ describe('admin/groups layout load', () => {
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/groups'),
url: new URL('http://localhost/admin/groups')
});
await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(mockGet).toHaveBeenCalledWith('/api/groups');
});
});

View File

@@ -1,43 +1,50 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { env } from '$env/dynamic/private';
import { parseBackendError } from '$lib/shared/errors';
import type { Actions, PageServerLoad } from './$types';
import type { components } from '$lib/generated/api';
export type InviteListItem = components['schemas']['InviteListItemDTO'];
export interface InviteListItem {
id: string;
code: string;
displayCode: string;
label?: string;
useCount: number;
maxUses?: number;
expiresAt?: string;
revoked: boolean;
status: string;
createdAt: string;
shareableUrl: string;
}
export type UserGroup = components['schemas']['UserGroup'];
const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const;
type InviteStatus = (typeof VALID_STATUSES)[number];
export const load: PageServerLoad = async ({ url, fetch }) => {
const rawStatus = url.searchParams.get('status');
const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus)
? (rawStatus as InviteStatus)
: 'ACTIVE';
const api = createApiClient(fetch);
const status = url.searchParams.get('status') ?? 'active';
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const [invitesResult, groupsResult] = await Promise.all([
api.GET('/api/invites', { params: { query: { status } } }),
api.GET('/api/groups')
const [invitesRes, groupsRes] = await Promise.all([
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
fetch(`${apiUrl}/api/groups`)
]);
let invites: InviteListItem[] = [];
let loadError: string | null = null;
if (!invitesResult.response.ok) {
const code = (invitesResult.error as unknown as { code?: string })?.code;
loadError = code ?? 'INTERNAL_ERROR';
if (!invitesRes.ok) {
const backendError = await parseBackendError(invitesRes);
loadError = backendError?.code ?? 'INTERNAL_ERROR';
} else {
invites = (invitesResult.data ?? []) as InviteListItem[];
invites = await invitesRes.json();
}
let groups: UserGroup[] = [];
let groupsLoadError: string | null = null;
if (!groupsResult.response.ok) {
const code = (groupsResult.error as unknown as { code?: string })?.code;
groupsLoadError = code ?? 'INTERNAL_ERROR';
if (!groupsRes.ok) {
const backendError = await parseBackendError(groupsRes);
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
} else {
const raw = groupsResult.data ?? [];
const raw: UserGroup[] = await groupsRes.json();
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
}
@@ -56,30 +63,42 @@ export const actions = {
const expiresAt = (formData.get('expiresAt') as string) || undefined;
const groupIds = formData.getAll('groupIds') as string[];
const api = createApiClient(fetch);
const result = await api.POST('/api/invites', {
body: { label, maxUses, prefillFirstName, prefillLastName, prefillEmail, expiresAt, groupIds }
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label,
maxUses,
prefillFirstName,
prefillLastName,
prefillEmail,
expiresAt,
groupIds
})
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
}
return { created: result.data! as InviteListItem };
const created: InviteListItem = await res.json();
return { created };
},
revoke: async ({ request, fetch }) => {
const formData = await request.formData();
const id = formData.get('id') as string | null;
if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') });
const id = formData.get('id') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
}
return { revoked: id };

View File

@@ -1,284 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
import { load, actions } from './+page.server';
import type { UserGroup } from './+page.server';
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
// the void and the Record<string, any> from the generic constraint.
type LoadData = {
invites: unknown[];
status: string;
loadError: string | null;
groups: UserGroup[];
groupsLoadError: string | null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFetch = (...args: any[]) => any;
function mockResponse(ok: boolean, body: unknown, status = 200) {
return {
ok,
status,
json: async () => body,
text: async () => JSON.stringify(body),
headers: new Headers({ 'content-type': 'application/json' })
} as unknown as Response;
}
describe('admin/invites load()', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
function event(status = 'active') {
const url = new URL(`http://localhost/admin/invites?status=${status}`);
return {
url,
request: new Request(url),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('returns groups array alongside invites when both succeed', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups).toHaveLength(2);
expect(result.groupsLoadError).toBeNull();
});
it('returns groups sorted alphabetically by name', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Zebra', permissions: [] },
{ id: 'g-2', name: 'Alfa', permissions: [] },
{ id: 'g-3', name: 'Mitte', permissions: [] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
});
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = (await load(event())) as LoadData;
expect(result.groups).toEqual([]);
expect(result.groupsLoadError).toBe('FORBIDDEN');
});
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, null, 500));
const result = (await load(event())) as LoadData;
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
});
it('fetches invites and groups in parallel (both URLs called)', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(true, []));
await load(event());
expect(mockFetch).toHaveBeenCalledTimes(2);
// createApiClient calls fetch(Request, {}), not fetch(string, init)
const urls = mockFetch.mock.calls.map((call) => (call[0] as Request).url);
expect(urls).toEqual(
expect.arrayContaining([
expect.stringContaining('/api/invites'),
expect.stringContaining('/api/groups')
])
);
});
});
describe('admin/invites create action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
const successBody = {
id: 'inv-1',
code: 'ABCDE12345',
displayCode: 'ABCDE-12345',
status: 'active',
revoked: false,
useCount: 0,
createdAt: '2026-01-01T00:00:00Z',
shareableUrl: 'http://localhost/register?code=ABCDE12345'
};
it('includes groupIds array in POST body when checkboxes are checked', async () => {
const fd = new FormData();
fd.append('groupIds', 'g-1');
fd.append('groupIds', 'g-2');
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
// createApiClient calls fetch(Request, {}), not fetch(string, init)
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
expect(req).toBeInstanceOf(Request);
expect(req.url).toContain('/api/invites');
const sent = await req.json();
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
});
it('sends groupIds: [] when no checkboxes are checked', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
expect(req).toBeInstanceOf(Request);
const sent = await req.json();
expect(sent.groupIds).toEqual([]);
});
it('returns created invite on success', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
const result = await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ created: expect.objectContaining({ id: 'inv-1' }) });
});
it('returns fail with backend error code when create returns non-OK', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 403, data: { createError: 'FORBIDDEN' } });
});
it('falls back to INTERNAL_ERROR when create error body has no code', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(false, null, 500));
const result = await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 500, data: { createError: 'INTERNAL_ERROR' } });
});
it('includes expiresAt in POST body when provided', async () => {
const fd = new FormData();
fd.append('expiresAt', '2026-12-31');
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
const sent = await req.json();
expect(sent.expiresAt).toBe('2026-12-31');
});
});
describe('admin/invites revoke action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
it('calls DELETE /api/invites/{id} via createApiClient', async () => {
const fd = new FormData();
fd.append('id', 'inv-abc');
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
expect(req).toBeInstanceOf(Request);
expect(req.url).toContain('/api/invites/inv-abc');
expect(req.method).toBe('DELETE');
});
it('returns revoked id on success', async () => {
const fd = new FormData();
fd.append('id', 'inv-abc');
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
const result = await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toEqual({ revoked: 'inv-abc' });
});
it('returns fail with backend error code when revoke returns non-OK', async () => {
const fd = new FormData();
fd.append('id', 'inv-abc');
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'NOT_FOUND' }, 404));
const result = await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toMatchObject({ status: 404, data: { revokeError: 'NOT_FOUND' } });
});
it('returns fail(400) when revoke id is missing', async () => {
const result = await actions.revoke({
request: new Request('http://localhost', { method: 'POST', body: new FormData() }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 400 });
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
import { load, actions } from './+page.server';
import type { UserGroup } from './+page.server';
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
// the void and the Record<string, any> from the generic constraint.
type LoadData = {
invites: unknown[];
status: string;
loadError: string | null;
groups: UserGroup[];
groupsLoadError: string | null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFetch = (...args: any[]) => any;
function mockResponse(ok: boolean, body: unknown, status = 200) {
return {
ok,
status,
json: async () => body,
text: async () => JSON.stringify(body),
headers: new Headers({ 'content-type': 'application/json' })
} as unknown as Response;
}
describe('admin/invites load()', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
function event(status = 'active') {
return {
url: new URL(`http://localhost/admin/invites?status=${status}`),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('returns groups array alongside invites when both succeed', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups).toHaveLength(2);
expect(result.groupsLoadError).toBeNull();
});
it('returns groups sorted alphabetically by name', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Zebra', permissions: [] },
{ id: 'g-2', name: 'Alfa', permissions: [] },
{ id: 'g-3', name: 'Mitte', permissions: [] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
});
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = (await load(event())) as LoadData;
expect(result.groups).toEqual([]);
expect(result.groupsLoadError).toBe('FORBIDDEN');
});
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, null, 500));
const result = (await load(event())) as LoadData;
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
});
it('fetches invites and groups in parallel (both URLs called)', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(true, []));
await load(event());
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
});
});
describe('admin/invites create action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
const successBody = {
id: 'inv-1',
code: 'ABCDE12345',
displayCode: 'ABCDE-12345',
status: 'active',
revoked: false,
useCount: 0,
createdAt: '2026-01-01T00:00:00Z',
shareableUrl: 'http://localhost/register?code=ABCDE12345'
};
it('includes groupIds array in POST body when checkboxes are checked', async () => {
const fd = new FormData();
fd.append('groupIds', 'g-1');
fd.append('groupIds', 'g-2');
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const sent = JSON.parse(init.body as string);
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
});
it('sends groupIds: [] when no checkboxes are checked', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const sent = JSON.parse(init.body as string);
expect(sent.groupIds).toEqual([]);
});
});

View File

@@ -26,46 +26,26 @@ beforeEach(() => vi.clearAllMocks());
describe('admin layout load — permission check', () => {
it('throws 403 when user has no admin permission', async () => {
await expect(
load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin'),
url: new URL('http://localhost/admin'),
locals: { user: noPermUser }
})
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: noPermUser } })
).rejects.toMatchObject({ status: 403 });
});
it('throws 403 when user is undefined', async () => {
await expect(
load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin'),
url: new URL('http://localhost/admin'),
locals: { user: undefined }
})
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } })
).rejects.toMatchObject({ status: 403 });
});
it('throws 403 when user has no groups', async () => {
await expect(
load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin'),
url: new URL('http://localhost/admin'),
locals: { user: { groups: [] } }
})
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } })
).rejects.toMatchObject({ status: 403 });
});
it('allows access for a user with ADMIN_TAG only', async () => {
mockApi([], [], []);
await expect(
load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin'),
url: new URL('http://localhost/admin'),
locals: { user: tagAdminUser }
})
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: tagAdminUser } })
).resolves.toBeDefined();
});
@@ -83,8 +63,6 @@ describe('admin layout load — permission check', () => {
const result = await load({
fetch: mockFetch as unknown as typeof fetch,
request: new Request('http://localhost/admin'),
url: new URL('http://localhost/admin'),
locals: { user: adminUser }
});

View File

@@ -15,12 +15,7 @@ describe('admin/ocr/[personId] — load', () => {
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
});
const result = (await load({
params: { personId },
fetch,
request: new Request('http://localhost/admin/ocr/123'),
url: new URL('http://localhost/admin/ocr/123')
} as never))!;
const result = (await load({ params: { personId }, fetch } as never))!;
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
});
@@ -32,12 +27,7 @@ describe('admin/ocr/[personId] — load', () => {
});
await expect(
load({
params: { personId: 'unknown-id' },
fetch,
request: new Request('http://localhost/admin/ocr/unknown-id'),
url: new URL('http://localhost/admin/ocr/unknown-id')
} as never)
load({ params: { personId: 'unknown-id' }, fetch } as never)
).rejects.toMatchObject({ status: 404 });
});
});

View File

@@ -14,11 +14,7 @@ describe('admin/ocr/global — load', () => {
data: { runs: [{ id: 'run1' }], personNames: {} }
});
const result = (await load({
fetch,
request: new Request('http://localhost/admin/ocr/global'),
url: new URL('http://localhost/admin/ocr/global')
} as never))!;
const result = (await load({ fetch } as never))!;
expect(result.history.runs).toHaveLength(1);
});
@@ -26,12 +22,6 @@ describe('admin/ocr/global — load', () => {
it('throws error when API call fails', async () => {
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
await expect(
load({
fetch,
request: new Request('http://localhost/admin/ocr/global'),
url: new URL('http://localhost/admin/ocr/global')
} as never)
).rejects.toMatchObject({ status: 500 });
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 });
});
});

View File

@@ -14,11 +14,7 @@ describe('admin/ocr — load', () => {
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
});
const result = (await load({
fetch,
request: new Request('http://localhost/admin/ocr'),
url: new URL('http://localhost/admin/ocr')
} as never))!;
const result = (await load({ fetch } as never))!;
expect(result.trainingInfo.availableBlocks).toBe(10);
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
@@ -27,12 +23,6 @@ describe('admin/ocr — load', () => {
it('throws 503 when OCR API call fails', async () => {
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
await expect(
load({
fetch,
request: new Request('http://localhost/admin/ocr'),
url: new URL('http://localhost/admin/ocr')
} as never)
).rejects.toMatchObject({ status: 503 });
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 });
});
});

View File

@@ -15,14 +15,6 @@ const failureMessage = $derived(
? m.admin_system_import_failed_no_spreadsheet()
: m.admin_system_import_failed_internal()
);
function reasonLabel(code: string): string {
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
if (code === 'S3_UPLOAD_FAILED') return m.import_reason_s3_upload_failed();
if (code === 'ALREADY_EXISTS') return m.import_reason_already_exists();
return code;
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -56,41 +48,6 @@ function reasonLabel(code: string): string {
</p>
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
</div>
<div aria-live="polite">
{#if importStatus.skipped > 0}
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-amber-900">
<summary class="flex min-h-[44px] cursor-pointer list-none items-center gap-2 py-2">
<svg
aria-hidden="true"
class="details-chevron h-4 w-4 shrink-0 motion-safe:transition-transform"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 4l4 4-4 4" />
</svg>
<div>
<span data-testid="skipped-count" class="block text-base font-bold"
>{importStatus.skipped}</span
>
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
{m.admin_system_import_skipped_label()}
</span>
</div>
</summary>
<ul class="mt-3 max-h-64 space-y-1 overflow-y-auto">
{#each importStatus.skippedFiles as skipped (skipped.filename)}
<li class="font-mono text-sm text-ink-2">
{skipped.filename}{reasonLabel(skipped.reason)}
</li>
{/each}
</ul>
</details>
{/if}
</div>
<button
data-import-trigger
onclick={ontrigger}
@@ -122,9 +79,3 @@ function reasonLabel(code: string): string {
</button>
{/if}
</div>
<style>
details[open] .details-chevron {
transform: rotate(90deg);
}
</style>

View File

@@ -8,8 +8,6 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
state: 'IDLE',
statusCode: 'IMPORT_IDLE',
processed: 0,
skipped: 0,
skippedFiles: [],
startedAt: null,
...overrides
});
@@ -130,106 +128,4 @@ describe('ImportStatusCard', () => {
await getByRole('button').click();
expect(ontrigger).toHaveBeenCalledOnce();
});
it('shows skipped count when DONE and skipped > 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'DONE',
statusCode: 'IMPORT_DONE',
processed: 10,
skipped: 3,
skippedFiles: [
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
]
}),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).toHaveTextContent('3');
});
it('shows skipped filenames in collapsible list when DONE and skipped > 0', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'DONE',
statusCode: 'IMPORT_DONE',
processed: 5,
skipped: 1,
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
}),
ontrigger: () => {}
}
});
await expect.element(getByText('fake.pdf')).toBeInTheDocument();
});
it('does not show skipped section when DONE and skipped is 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
it('does not show skipped section when RUNNING even with skipped > 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'RUNNING',
statusCode: 'IMPORT_RUNNING',
processed: 5,
skipped: 2,
skippedFiles: [
{ filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
{ filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
]
}),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
it('does not show skipped section when FAILED even with skipped > 0', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'FAILED',
statusCode: 'IMPORT_FAILED_INTERNAL',
skipped: 1,
skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
}),
ontrigger: () => {}
}
});
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
it('shows raw reason code for unknown skip reasons', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'DONE',
statusCode: 'IMPORT_DONE',
processed: 1,
skipped: 1,
skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
}),
ontrigger: () => {}
}
});
await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,6 @@
export type SkippedFile = {
filename: string;
reason: string;
};
export type ImportStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
statusCode: string;
processed: number;
skipped: number;
skippedFiles: SkippedFile[];
startedAt: string | null;
};

View File

@@ -21,7 +21,6 @@ describe('tags/[id] — load function', () => {
const result = await load({
params: { id: 't1' },
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
request: new Request('http://localhost/admin/tags/t1'),
url
} as never);
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
@@ -32,7 +31,6 @@ describe('tags/[id] — load function', () => {
const result = await load({
params: { id: 't1' },
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
request: new Request('http://localhost/admin/tags/t1'),
url
} as never);
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);

View File

@@ -44,22 +44,14 @@ const sampleTree = [
describe('admin/tags layout load', () => {
it('returns the tree list', async () => {
mockTreeApi(sampleTree);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/tags'),
url: new URL('http://localhost/admin/tags')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.tree).toHaveLength(2);
expect(result.tree[0].name).toBe('Familie');
});
it('returns an empty tree when the API returns nothing', async () => {
mockTreeApi([]);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/tags'),
url: new URL('http://localhost/admin/tags')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.tree).toEqual([]);
});
@@ -68,21 +60,13 @@ describe('admin/tags layout load', () => {
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/tags'),
url: new URL('http://localhost/admin/tags')
});
await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(mockGet).toHaveBeenCalledWith('/api/tags/tree');
});
it('flattens the tree into a flat tags array', async () => {
mockTreeApi(sampleTree);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/tags'),
url: new URL('http://localhost/admin/tags')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
// Both parent and child should be in the flat array
expect(result.tags).toHaveLength(3);
expect(result.tags.map((t) => t.name)).toContain('Eltern');
@@ -90,22 +74,14 @@ describe('admin/tags layout load', () => {
it('preserves parentId on child tags in the flat array', async () => {
mockTreeApi(sampleTree);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/tags'),
url: new URL('http://localhost/admin/tags')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const child = result.tags.find((t) => t.name === 'Eltern');
expect(child?.parentId).toBe('parent1');
});
it('sets parentId to undefined on root tags in the flat array', async () => {
mockTreeApi(sampleTree);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/tags'),
url: new URL('http://localhost/admin/tags')
});
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const root = result.tags.find((t) => t.name === 'Familie');
expect(root?.parentId).toBeUndefined();
});

View File

@@ -2,7 +2,6 @@ import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
const user = locals.user;
@@ -46,17 +45,21 @@ export const actions: Actions = {
groupIds: data.getAll('groupIds') as string[]
};
const api = createApiClient(fetch);
const result = await api.PUT('/api/users/{id}', {
params: { path: { id: params.id } },
// Body may contain null for fields the user cleared; the backend treats
// null as "clear this field". Cast to satisfy the optional-only spec type.
body: body as components['schemas']['AdminUpdateUserRequest']
const res = await fetch(`/api/users/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
if (!res.ok) {
let code: string | undefined;
try {
const json = await res.json();
code = json?.code;
} catch {
// ignore
}
return fail(res.status, { error: getErrorMessage(code) });
}
return { success: true };

View File

@@ -1,199 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { load, actions } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
function mockApi(methods: Partial<Record<'GET' | 'PUT' | 'DELETE', ReturnType<typeof vi.fn>>>) {
vi.mocked(createApiClient).mockReturnValue(methods as ReturnType<typeof createApiClient>);
}
// ─── load() ──────────────────────────────────────────────────────────────────
describe('admin/users/[id] load()', () => {
beforeEach(() => vi.clearAllMocks());
function makeEvent(permissions: string[] = ['ADMIN']) {
return {
params: { id: 'user-123' },
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: { groups: [{ permissions }] } },
request: new Request('http://localhost/admin/users/user-123'),
url: new URL('http://localhost/admin/users/user-123')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('throws 403 when the user lacks the ADMIN permission', async () => {
let thrown: unknown;
try {
await load(makeEvent(['READ_ALL']));
} catch (e) {
thrown = e;
}
expect((thrown as { status: number }).status).toBe(403);
});
it('throws 404 when the backend returns non-ok for the user lookup', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: false, status: 404 }, data: undefined })
.mockResolvedValueOnce({ response: { ok: true }, data: [] });
mockApi({ GET: mockGet });
let thrown: unknown;
try {
await load(makeEvent());
} catch (e) {
thrown = e;
}
expect((thrown as { status: number }).status).toBe(404);
});
it('returns editUser and groups on success', async () => {
const editUser = { id: 'user-123', email: 'max@example.com', firstName: 'Max' };
const groups = [
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
];
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true }, data: editUser })
.mockResolvedValueOnce({ response: { ok: true }, data: groups });
mockApi({ GET: mockGet });
const result = await load(makeEvent());
expect(result).toMatchObject({
editUser: expect.objectContaining({ id: 'user-123' }),
groups: expect.arrayContaining([expect.objectContaining({ id: 'g-1' })])
});
});
});
// ─── update action ────────────────────────────────────────────────────────────
describe('admin/users/[id] update action', () => {
beforeEach(() => vi.clearAllMocks());
function makeUpdateRequest(fields: Record<string, string | string[]> = {}) {
const fd = new FormData();
const defaults: Record<string, string> = {
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com'
};
for (const [k, v] of Object.entries({ ...defaults, ...fields })) {
if (Array.isArray(v)) {
v.forEach((item) => fd.append(k, item));
} else {
fd.append(k, v);
}
}
return new Request('http://localhost', { method: 'POST', body: fd });
}
function makeEvent(request: Request) {
return {
params: { id: 'user-123' },
request,
fetch: vi.fn() as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('calls PUT /api/users/{id} and returns success: true on 200', async () => {
const mockPut = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
mockApi({ PUT: mockPut });
const result = await actions.update(makeEvent(makeUpdateRequest()));
expect(mockPut).toHaveBeenCalledWith(
'/api/users/{id}',
expect.objectContaining({ params: { path: { id: 'user-123' } } })
);
expect(result).toEqual({ success: true });
});
it('returns fail with backend error code when PUT returns non-OK', async () => {
const mockPut = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
mockApi({ PUT: mockPut });
const result = await actions.update(makeEvent(makeUpdateRequest()));
expect(result).toMatchObject({ status: 403 });
});
it('returns fail with generic message when error body has no code field', async () => {
const mockPut = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 500 }, error: null });
mockApi({ PUT: mockPut });
const result = await actions.update(makeEvent(makeUpdateRequest()));
expect(result).toMatchObject({ status: 500 });
});
it('returns fail without calling backend when passwords do not match', async () => {
const mockPut = vi.fn();
mockApi({ PUT: mockPut });
const result = await actions.update(
makeEvent(makeUpdateRequest({ newPassword: 'abc', confirmPassword: 'xyz' }))
);
expect(mockPut).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 400 });
});
});
// ─── delete action ────────────────────────────────────────────────────────────
describe('admin/users/[id] delete action', () => {
beforeEach(() => vi.clearAllMocks());
function makeEvent() {
return {
params: { id: 'user-123' },
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users/user-123')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('redirects to /admin/users on successful delete', async () => {
const mockDelete = vi.fn().mockResolvedValue({ response: { ok: true } });
mockApi({ DELETE: mockDelete });
let redirectLocation: string | null = null;
try {
await actions.delete(makeEvent());
} catch (e: unknown) {
const r = e as { location?: string };
redirectLocation = r.location ?? null;
}
expect(redirectLocation).toBe('/admin/users');
});
it('returns fail when delete returns non-OK', async () => {
const mockDelete = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
mockApi({ DELETE: mockDelete });
const result = await actions.delete(makeEvent());
expect(result).toMatchObject({ status: 403 });
});
});

View File

@@ -19,24 +19,14 @@ describe('admin/users layout load', () => {
{ id: 'u1', email: 'alice@example.com' },
{ id: 'u2', email: 'bob@example.com' }
]);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users'),
url: new URL('http://localhost/admin/users')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.users).toHaveLength(2);
expect(result.users[0].email).toBe('alice@example.com');
});
it('returns an empty array when the API returns nothing', async () => {
mockApi([]);
const result = await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users'),
url: new URL('http://localhost/admin/users')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.users).toEqual([]);
});
@@ -45,12 +35,7 @@ describe('admin/users layout load', () => {
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/admin/users'),
url: new URL('http://localhost/admin/users')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(mockGet).toHaveBeenCalledWith('/api/users');
});
});

View File

@@ -1,6 +1,4 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
loadError
};
}
export const actions = {
'dismiss-notification': async ({ request, fetch }) => {
const data = await request.formData();
const raw = data.get('notificationId');
const notificationId = typeof raw === 'string' ? raw : null;
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
const api = createApiClient(fetch);
const result = await api.PATCH('/api/notifications/{id}/read', {
params: { path: { id: notificationId } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
},
'mark-all-read': async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.POST('/api/notifications/read-all');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
}
};

View File

@@ -76,6 +76,14 @@ async function onFilterChange(v: FilterValue) {
});
}
async function onMarkRead(n: NotificationItem) {
await notificationStore.markRead(n);
}
async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
const isEmpty = $derived(displayFeed.length === 0);
@@ -100,11 +108,7 @@ function retry() {
{#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} />
{:else}
<ChronikFuerDichBox
unread={unread}
optimisticMarkRead={notificationStore.optimisticMarkRead}
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
/>
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
<div class="mt-6">
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />

View File

@@ -1,10 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load, actions } from './+page.server';
import { load } from './+page.server';
const mockApi = {
GET: vi.fn(),
PATCH: vi.fn(),
POST: vi.fn()
GET: vi.fn()
};
vi.mock('$lib/shared/api.server', () => ({
@@ -31,11 +29,7 @@ beforeEach(() => {
describe('aktivitaeten/load — core', () => {
it('requests only unread notifications for Für-dich', async () => {
mockSuccess();
await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl()
} as never);
await load({ fetch, url: buildUrl() } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
params: { query: { read: false, page: 0, size: 20 } }
});
@@ -51,11 +45,7 @@ describe('aktivitaeten/load — core', () => {
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
});
const result = await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl()
} as never);
const result = await load({ fetch, url: buildUrl() } as never);
expect(result.activityFeed).toEqual(feed);
expect(result.unreadNotifications).toEqual(unread);
@@ -71,11 +61,7 @@ describe('aktivitaeten/load — core', () => {
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
});
const result = await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl()
} as never);
const result = await load({ fetch, url: buildUrl() } as never);
expect(result.loadError).toBe('activity');
expect(result.activityFeed).toEqual([]);
@@ -83,19 +69,11 @@ describe('aktivitaeten/load — core', () => {
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
const validResult = await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl('?filter=fuer-dich')
} as never);
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
expect(validResult.filter).toBe('fuer-dich');
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
const invalidResult = await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl('?filter=bogus')
} as never);
const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never);
expect(invalidResult.filter).toBe('alle');
});
});
@@ -103,11 +81,7 @@ describe('aktivitaeten/load — core', () => {
describe('aktivitaeten/load — kinds param per filter', () => {
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
mockSuccess();
await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl()
} as never);
await load({ fetch, url: buildUrl() } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40 } }
});
@@ -115,11 +89,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
mockSuccess();
await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl('?filter=fuer-dich')
} as never);
await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40 } }
});
@@ -127,11 +97,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
mockSuccess();
await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl('?filter=hochgeladen')
} as never);
await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
});
@@ -139,11 +105,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
mockSuccess();
await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl('?filter=transkription')
} as never);
await load({ fetch, url: buildUrl('?filter=transkription') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: {
query: {
@@ -158,11 +120,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
mockSuccess();
await load({
fetch,
request: new Request('http://localhost/aktivitaeten'),
url: buildUrl('?filter=kommentare')
} as never);
await load({ fetch, url: buildUrl('?filter=kommentare') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: {
query: {
@@ -175,84 +133,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
expect(call[1].params.query.kinds).toHaveLength(2);
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function makeActionEvent(formData: FormData): any {
return {
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
fetch
};
}
describe('aktivitaeten/actions — dismiss-notification', () => {
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 400 });
expect(mockApi.PATCH).not.toHaveBeenCalled();
});
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
await actions['dismiss-notification'](makeActionEvent(fd));
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
params: { path: { id: 'n-abc' } }
});
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.PATCH.mockResolvedValue({
response: { ok: false, status: 403 },
error: { code: 'NOTIFICATION_NOT_FOUND' }
});
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toMatchObject({ status: 403 });
});
});
describe('aktivitaeten/actions — mark-all-read', () => {
it('calls POST /api/notifications/read-all', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: false, status: 500 },
error: { code: 'INTERNAL_ERROR' }
});
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 500 });
});
});

View File

@@ -40,7 +40,6 @@ describe('korrespondenz load — no senderId', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/briefwechsel'),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
@@ -70,7 +69,6 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
const result = await load({
url: makeUrl({ senderId: 'p1' }),
request: new Request('http://localhost/briefwechsel'),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
@@ -110,7 +108,6 @@ describe('korrespondenz load — senderId and receiverId set', () => {
const result = await load({
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
request: new Request('http://localhost/briefwechsel'),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
@@ -140,7 +137,6 @@ describe('korrespondenz load — canWrite', () => {
const result = await load({
url: makeUrl({ senderId: 'p1' }),
request: new Request('http://localhost/briefwechsel'),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: writeUser }
});
@@ -164,7 +160,6 @@ describe('korrespondenz load — canWrite', () => {
const result = await load({
url: makeUrl({ senderId: 'p1' }),
request: new Request('http://localhost/briefwechsel'),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
@@ -193,7 +188,6 @@ describe('korrespondenz load — backend error', () => {
await expect(
load({
url: makeUrl({ senderId: 'p1' }),
request: new Request('http://localhost/briefwechsel'),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
})

View File

@@ -23,9 +23,7 @@ describe('document detail load — happy path', () => {
const result = await load({
params: { id: '123' },
fetch: mockFetch as unknown as typeof fetch,
request: new Request('http://localhost/documents/123'),
url: new URL('http://localhost/documents/123')
fetch: mockFetch as unknown as typeof fetch
});
expect(result.document.title).toBe('Testbrief');
@@ -46,12 +44,7 @@ describe('document detail load — error paths', () => {
const mockFetch = vi.fn();
await expect(
load({
params: { id: 'missing' },
fetch: mockFetch as unknown as typeof fetch,
request: new Request('http://localhost/documents/123'),
url: new URL('http://localhost/documents/123')
})
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
).rejects.toMatchObject({ status: 404 });
});
@@ -66,12 +59,7 @@ describe('document detail load — error paths', () => {
const mockFetch = vi.fn();
await expect(
load({
params: { id: 'secret' },
fetch: mockFetch as unknown as typeof fetch,
request: new Request('http://localhost/documents/123'),
url: new URL('http://localhost/documents/123')
})
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
).rejects.toMatchObject({ status: 403 });
});
@@ -86,12 +74,7 @@ describe('document detail load — error paths', () => {
const mockFetch = vi.fn();
await expect(
load({
params: { id: 'any' },
fetch: mockFetch as unknown as typeof fetch,
request: new Request('http://localhost/documents/123'),
url: new URL('http://localhost/documents/123')
})
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
).rejects.toMatchObject({ location: '/login' });
});
});

View File

@@ -6,11 +6,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({
locals,
request: new Request('http://localhost/documents/bulk-edit'),
url: new URL('http://localhost/documents/bulk-edit')
});
await load({ locals });
throw new Error('expected redirect to be thrown');
} catch (e) {
const err = e as { status?: number; location?: string };
@@ -23,11 +19,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
const locals = { user: { groups: [] } };
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({
locals,
request: new Request('http://localhost/documents/bulk-edit'),
url: new URL('http://localhost/documents/bulk-edit')
});
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);
@@ -38,11 +30,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
const locals = {};
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({
locals,
request: new Request('http://localhost/documents/bulk-edit'),
url: new URL('http://localhost/documents/bulk-edit')
});
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);
@@ -52,11 +40,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
it('returns canWrite=true for a WRITE_ALL user', async () => {
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
// @ts-expect-error — partial event shape sufficient for this guard
const result = await load({
locals,
request: new Request('http://localhost/documents/bulk-edit'),
url: new URL('http://localhost/documents/bulk-edit')
});
const result = await load({ locals });
expect(result).toEqual({ canWrite: true });
});
@@ -68,11 +52,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
};
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({
locals,
request: new Request('http://localhost/documents/bulk-edit'),
url: new URL('http://localhost/documents/bulk-edit')
});
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);

View File

@@ -33,7 +33,6 @@ describe('documents page load — search params', () => {
await load({
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -58,7 +57,6 @@ describe('documents page load — search params', () => {
await load({
url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -83,7 +81,6 @@ describe('documents page load — search params', () => {
await load({
url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -114,7 +111,6 @@ describe('documents page load — search params', () => {
const result = await load({
url: makeUrl({ q: 'test' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -133,7 +129,6 @@ describe('documents page load — search params', () => {
const result = await load({
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -153,11 +148,7 @@ describe('documents page load — auth redirect', () => {
} as ReturnType<typeof createApiClient>);
await expect(
load({
url: makeUrl(),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
})
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
).rejects.toMatchObject({ location: '/login' });
});
});
@@ -170,11 +161,7 @@ describe('documents page load — network error fallback', () => {
GET: vi.fn().mockRejectedValue(new Error('Network failure'))
} as ReturnType<typeof createApiClient>);
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.error).toBeTruthy();
expect(result.items).toEqual([]);
@@ -212,7 +199,6 @@ describe('documents page load — person name resolution', () => {
const result = await load({
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -224,7 +210,6 @@ describe('documents page load — person name resolution', () => {
const result = await load({
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -236,7 +221,6 @@ describe('documents page load — person name resolution', () => {
const result = await load({
url: makeUrl({ senderId: 'not-a-uuid' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
@@ -250,7 +234,6 @@ describe('documents page load — person name resolution', () => {
const result = await load({
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});

View File

@@ -70,16 +70,4 @@ describe('login page', () => {
.element(page.getByRole('link', { name: /passwort vergessen/i }))
.toHaveAttribute('href', '/forgot-password');
});
it('shows rate-limit alert with clock icon when rateLimited is true', async () => {
render(LoginPage, {
props: {
data: { registered: false },
form: { error: 'Zu viele Anmeldeversuche.', rateLimited: true }
}
});
await expect.element(page.getByRole('alert')).toBeVisible();
await expect.element(page.getByText('Zu viele Anmeldeversuche.')).toBeVisible();
});
});

View File

@@ -33,7 +33,6 @@ it('never calls /api/documents/search regardless of URL params', async () => {
await load({
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -50,7 +49,6 @@ it('always fetches dashboard data regardless of URL params', async () => {
await load({
url: makeUrl({ q: 'Urlaub' }),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -112,7 +110,6 @@ describe('home page load — dashboard', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -150,7 +147,6 @@ describe('home page load — dashboard', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -172,7 +168,6 @@ describe('home page load — dashboard', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -194,7 +189,6 @@ describe('home page load — dashboard', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -219,7 +213,6 @@ describe('home page load — dashboard', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -239,7 +232,6 @@ describe('home page load — auth redirect', () => {
await expect(
load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0])
@@ -257,7 +249,6 @@ describe('home page load — network error fallback', () => {
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: contributorParent()
} as Parameters<typeof load>[0]);
@@ -277,7 +268,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()
@@ -299,7 +289,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()
@@ -321,7 +310,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()
@@ -344,7 +332,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true })
} as Parameters<typeof load>[0]);
@@ -365,7 +352,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()
@@ -383,7 +369,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false })
} as Parameters<typeof load>[0]);
@@ -413,7 +398,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()

View File

@@ -32,13 +32,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({
params: { id: 'p1' },
fetch: mockFetch,
request: new Request('http://localhost/persons/p1'),
url: new URL('http://localhost/persons/p1'),
locals: mockLocals
});
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
expect(result.person.firstName).toBe('Hans');
expect(result.sentDocuments).toHaveLength(1);
@@ -61,13 +55,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({
params: { id: 'p1' },
fetch: mockFetch,
request: new Request('http://localhost/persons/p1'),
url: new URL('http://localhost/persons/p1'),
locals: mockLocalsWriter
});
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
expect(result.canWrite).toBe(true);
});
@@ -88,13 +76,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({
params: { id: 'p1' },
fetch: mockFetch,
request: new Request('http://localhost/persons/p1'),
url: new URL('http://localhost/persons/p1'),
locals: mockLocals
});
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
expect(result.sentDocuments).toEqual([]);
expect(result.receivedDocuments).toEqual([]);
@@ -118,13 +100,7 @@ describe('person detail load — error paths', () => {
} as ReturnType<typeof createApiClient>);
await expect(
load({
params: { id: 'missing' },
fetch: mockFetch,
request: new Request('http://localhost/persons/p1'),
url: new URL('http://localhost/persons/p1'),
locals: mockLocals
})
load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals })
).rejects.toMatchObject({
status: 404
});
@@ -144,13 +120,7 @@ describe('person detail load — error paths', () => {
} as ReturnType<typeof createApiClient>);
await expect(
load({
params: { id: 'forbidden' },
fetch: mockFetch,
request: new Request('http://localhost/persons/p1'),
url: new URL('http://localhost/persons/p1'),
locals: mockLocals
})
load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals })
).rejects.toMatchObject({
status: 403
});

View File

@@ -1,13 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [
{
"description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.",
"matchPackageNames": ["com.bucket4j:bucket4j-core"],
"groupName": "bucket4j",
"automerge": true,
"matchUpdateTypes": ["patch"]
},
{
"matchPackagePatterns": ["^@tiptap/"],
"groupName": "tiptap",