Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
c92eda33b1 fix(document): fix test regressions from DocumentListItem migration
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m37s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Failing after 3m33s
CI / fail2ban Regex (pull_request) Failing after 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
- Use documentService.getDocumentById() in detail_stillReturnsTrainingLabels
  so the Document.full entity graph eager-loads trainingLabels
- Flatten makeItem() factory in DocumentList.svelte.test.ts (nested
  document: {} overrides broke item.id / item.documentDate access)
- Remove { document: {} } wrapper from DocumentMultiSelect.svelte.spec.ts
  mock responses — component now reads body.items directly as flat items
- Flatten single nested item in page.svelte.test.ts document list test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:14:44 +02:00
Marcel
4d0b8b1570 refactor(document): migrate frontend from DocumentSearchItem to flat DocumentListItem
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m14s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Failing after 3m32s
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 1m1s
All components, specs, and the generated API client now use the new
DocumentListItem shape — flat access (item.title, item.sender) instead of
the removed item.document.* nesting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:38:50 +02:00
Marcel
0f32332b63 test(document): add LazyInit guard + detail regression tests; prune Document.list graph
Remove trainingLabels from Document.list entity graph now that DocumentListItem
does not touch that association. Integration tests guard against future
LazyInitializationException regressions and confirm Document.full still
loads trainingLabels for the detail endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:38:50 +02:00
Marcel
eb7edd1382 refactor(document): replace DocumentSearchItem with flat DocumentListItem DTO
Eliminates excessive data exposure (OWASP API3:2023) — transcription,
filePath, fileHash, thumbnailKey, scriptType and other detail-only fields
are no longer serialised in the list API response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:37:21 +02:00
38 changed files with 744 additions and 1014 deletions

View File

@@ -29,6 +29,7 @@ import java.util.UUID;
}) })
@NamedEntityGraph(name = "Document.list", attributeNodes = { @NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"), @NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags") @NamedAttributeNode("tags")
}) })
@Entity @Entity

View File

@@ -0,0 +1,36 @@
package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public record DocumentListItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String originalFilename,
String thumbnailUrl,
LocalDate documentDate,
Person sender,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Person> receivers,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Tag> tags,
String archiveBox,
String archiveFolder,
String location,
String summary,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData
) {}

View File

@@ -1,18 +0,0 @@
package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.document.Document;
import java.util.List;
public record DocumentSearchItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Document document,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors
) {}

View File

@@ -7,7 +7,7 @@ import java.util.List;
public record DocumentSearchResult( public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<DocumentSearchItem> items, List<DocumentListItem> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long totalElements, long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -17,20 +17,12 @@ public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages int totalPages
) { ) {
/** public static DocumentSearchResult of(List<DocumentListItem> items) {
* Single-page convenience factory used by empty-result shortcuts and by tests that
* don't care about paging. Treats the whole list as page 0 of itself.
*/
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
int size = items.size(); int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1); return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
} }
/** public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
* Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
*/
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize(); int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize); int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages); return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);

View File

@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO; import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentBatchSummary; import org.raddatz.familienarchiv.document.DocumentBatchSummary;
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO; import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult; import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort; import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO; import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
@@ -736,7 +735,7 @@ public class DocumentService {
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements); return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
} }
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) { private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents); List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text); Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
@@ -744,7 +743,7 @@ public class DocumentService {
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds); Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds); Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
return colorResolved.stream().map(doc -> new DocumentSearchItem( return colorResolved.stream().map(doc -> toListItem(
doc, doc,
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()), matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
completionByDoc.getOrDefault(doc.getId(), 0), completionByDoc.getOrDefault(doc.getId(), 0),
@@ -752,6 +751,26 @@ public class DocumentService {
)).toList(); )).toList();
} }
private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
return new DocumentListItem(
doc.getId(),
doc.getTitle(),
doc.getOriginalFilename(),
doc.getThumbnailUrl(),
doc.getDocumentDate(),
doc.getSender(),
doc.getReceivers() != null ? List.copyOf(doc.getReceivers()) : List.of(),
doc.getTags() != null ? List.copyOf(doc.getTags()) : List.of(),
doc.getArchiveBox(),
doc.getArchiveFolder(),
doc.getLocation(),
doc.getSummary(),
completionPct,
contributors,
match
);
}
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) { private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
return transcriptionBlockQueryService.getCompletionStats(docIds); return transcriptionBlockQueryService.getCompletionStats(docIds);
} }

View File

@@ -27,7 +27,6 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.SearchMatchData; import org.raddatz.familienarchiv.document.SearchMatchData;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -130,16 +129,13 @@ class DocumentControllerTest {
@WithMockUser @WithMockUser
void search_responseBodyItemsContainMatchData() throws Exception { void search_responseBodyItemsContainMatchData() throws Exception {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
.title("Brief an Anna")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new SearchMatchData( var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of())))); .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), matchData))));
mockMvc.perform(get("/api/documents/search").param("q", "Brief")) mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -148,6 +144,27 @@ class DocumentControllerTest {
.value("Er schrieb einen langen Brief")); .value("Er schrieb einen langen Brief"));
} }
@Test
@WithMockUser
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), matchData))));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
// flat id field present at top of item (not nested under $.items[0].document.id)
.andExpect(jsonPath("$.items[0].id").value(docId.toString()))
// sensitive storage fields must never appear in list response
.andExpect(jsonPath("$.items[0].transcription").doesNotExist())
.andExpect(jsonPath("$.items[0].filePath").doesNotExist())
.andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
}
// ─── /api/documents/search pagination ───────────────────────────────────── // ─── /api/documents/search pagination ─────────────────────────────────────
@Test @Test

View File

@@ -127,7 +127,7 @@ class DocumentLazyLoadingTest {
PageRequest.of(0, 20)); PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0); assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() -> assertThatCode(() ->
result.items().forEach(i -> i.document().getSender().getLastName())) result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
} }

View File

@@ -0,0 +1,98 @@
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.ocr.TrainingLabel;
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.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* AC #2: Document with trainingLabels does not cause LazyInitializationException in search.
* AC #3: Detail API still returns trainingLabels after the Document.list graph change.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class DocumentListItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditLogQueryService auditLogQueryService;
@Autowired
DocumentRepository documentRepository;
@Autowired
DocumentService documentService;
@AfterEach
void cleanup() {
documentRepository.deleteAll();
}
@Test
void search_doesNotThrow_whenDocumentHasTrainingLabels() {
documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50)))
.doesNotThrowAnyException();
}
@Test
void search_returns_list_item_without_sensitive_fields_when_document_has_training_labels() {
documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent2.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0);
assertThat(item.id()).isNotNull();
assertThat(item.title()).isEqualTo("Kurrent Brief");
}
@Test
void detail_stillReturnsTrainingLabels() {
Document saved = documentRepository.save(Document.builder()
.title("Detail Test")
.originalFilename("detail_test.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
// Document.full entity graph (used by getDocumentById) must still load trainingLabels
Document loaded = documentService.getDocumentById(saved.getId());
assertThat(loaded.getTrainingLabels()).containsExactly(TrainingLabel.KURRENT_RECOGNITION);
}
}

View File

@@ -125,10 +125,10 @@ class DocumentSearchPagedIntegrationTest {
// No document id should appear on both pages — slicing must be exclusive. // No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream() var idsOnPage0 = page0.items().stream()
.map(item -> item.document().getId()) .map(item -> item.id())
.toList(); .toList();
var idsOnPage1 = page1.items().stream() var idsOnPage1 = page1.items().stream()
.map(item -> item.document().getId()) .map(item -> item.id())
.toList(); .toList();
for (UUID id : idsOnPage0) { for (UUID id : idsOnPage0) {
assertThat(idsOnPage1).doesNotContain(id); assertThat(idsOnPage1).doesNotContain(id);

View File

@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import java.util.List; import java.util.List;
@@ -14,14 +12,11 @@ import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest { class DocumentSearchResultTest {
private DocumentSearchItem item(UUID docId) { private DocumentListItem item(UUID docId) {
Document doc = Document.builder() return new DocumentListItem(
.id(docId) docId, "Test", "test.pdf", null, null, null,
.title("Test") List.of(), List.of(), null, null, null, null,
.originalFilename("test.pdf") 0, List.of(), SearchMatchData.empty());
.status(DocumentStatus.UPLOADED)
.build();
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
} }
@Test @Test
@@ -45,7 +40,7 @@ class DocumentSearchResultTest {
@Test @Test
void paged_factory_populates_paging_fields_from_pageable_and_total() { void paged_factory_populates_paging_fields_from_pageable_and_total() {
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID())); List<DocumentListItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L); DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
@@ -68,9 +63,10 @@ class DocumentSearchResultTest {
void of_exposes_items_with_completion_and_contributors() { void of_exposes_items_with_completion_and_contributors() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun"); ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf") DocumentListItem item = new DocumentListItem(
.status(DocumentStatus.UPLOADED).build(); id, "T", "t.pdf", null, null, null,
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor)); List.of(), List.of(), null, null, null, null,
75, List.of(actor), SearchMatchData.empty());
DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); DocumentSearchResult result = DocumentSearchResult.of(List.of(item));

View File

@@ -70,7 +70,7 @@ class DocumentServiceSortTest {
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE); "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
assertThat(result.items()).hasSize(2); assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
} }
// ─── RELEVANCE sort — pure text (no filters) ────────────────────────────── // ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
@@ -104,7 +104,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE); "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1); assertThat(result.items().get(0).id()).isEqualTo(id1);
} }
@Test @Test
@@ -121,7 +121,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE); "Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1); assertThat(result.items().get(0).id()).isEqualTo(id1);
} }
// ─── RELEVANCE sort — overflow guard ───────────────────────────────────── // ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
@@ -156,7 +156,7 @@ class DocumentServiceSortTest {
DocumentSort.RELEVANCE, null, null, PAGE); DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items()).hasSize(1); assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId); assertThat(result.items().get(0).id()).isEqualTo(uuidId);
} }
// ─── RELEVANCE sort — text + active filter ──────────────────────────────── // ─── RELEVANCE sort — text + active filter ────────────────────────────────

View File

@@ -11,7 +11,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.annotation.AnnotationService; import org.raddatz.familienarchiv.document.annotation.AnnotationService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
import org.raddatz.familienarchiv.document.DocumentSearchItem; import org.raddatz.familienarchiv.document.DocumentListItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult; import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort; import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO; import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
@@ -1444,7 +1444,7 @@ class DocumentServiceTest {
assertThat(result.totalPages()).isEqualTo(3); assertThat(result.totalPages()).isEqualTo(3);
assertThat(result.items()).hasSize(50); assertThat(result.items()).hasSize(50);
// Page 1 (offset 50) under ascending sender sort should start at L050 // Page 1 (offset 50) under ascending sender sort should start at L050
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050"); assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
} }
@Test @Test
@@ -1565,7 +1565,7 @@ class DocumentServiceTest {
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED); null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
assertThat(result.items()).hasSize(2); assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender"); assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
} }
// ─── searchDocuments — RECEIVER sort, empty receivers ─────────────────────── // ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1584,7 +1584,7 @@ class DocumentServiceTest {
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED); null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
assertThat(result.items()).extracting(item -> item.document().getTitle()) assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("Has Receiver", "No Receivers"); .containsExactly("Has Receiver", "No Receivers");
} }
@@ -1607,7 +1607,7 @@ class DocumentServiceTest {
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED); null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null") // null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(item -> item.document().getTitle()) assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("smith doc", "Null lastname doc"); .containsExactly("smith doc", "Null lastname doc");
} }

View File

@@ -522,7 +522,6 @@
"notification_filter_unread": "Ungelesen", "notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung", "notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort", "notification_filter_reply": "Antwort",
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden", "notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen", "notification_empty_history": "Keine Benachrichtigungen",

View File

@@ -522,7 +522,6 @@
"notification_filter_unread": "Unread", "notification_filter_unread": "Unread",
"notification_filter_mention": "Mention", "notification_filter_mention": "Mention",
"notification_filter_reply": "Reply", "notification_filter_reply": "Reply",
"notification_error_generic": "Action failed. Please try again.",
"notification_mark_all_read_aria": "Mark all notifications as read", "notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older", "notification_load_more": "Load older",
"notification_empty_history": "No notifications", "notification_empty_history": "No notifications",

View File

@@ -522,7 +522,6 @@
"notification_filter_unread": "No leídas", "notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención", "notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta", "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_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores", "notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones", "notification_empty_history": "Sin notificaciones",

View File

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

View File

@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
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';
});
function notif(partial: Partial<NotificationItem>): NotificationItem { function notif(partial: Partial<NotificationItem>): NotificationItem {
return { return {
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => { it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull(); expect(zero).not.toBeNull();
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => { it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => { it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })], unread: [notif({ id: 'a' }), notif({ id: 'b' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('2 neu')).toBeInTheDocument(); 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 () => { it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
// Wait for render // Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument(); 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 () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); 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 () => { it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
}); });
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => { it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead onMarkAllRead
}); });
await userEvent.click(page.getByText('Alle gelesen')); 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 () => { it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' }); const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [n], unread: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]' '[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
dismiss?.click(); dismiss?.click();
expect(optimisticMarkRead).toHaveBeenCalledTimes(1); expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz'); expect(onMarkRead.mock.calls[0][0]).toEqual(n);
}); });
it('mention row href includes both commentId and annotationId when annotationId is present', async () => { it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9' annotationId: 'annot-9'
}) })
], ],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector( const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' '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 () => { it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })], unread: [notif({ id: 'x' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie. // Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull(); 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 ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
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';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1', id: 'n-1',
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => { describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => { it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} } props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
}); });
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ actorName: 'Bertha' })], unread: [mention({ actorName: 'Bertha' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })], unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible(); .toBeVisible();
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => { it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const item = mention({ id: 'n-7' }); const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} } props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement; ) as HTMLElement;
dismiss.click(); dismiss.click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7'); expect(onMarkRead).toHaveBeenCalledWith(item);
}); });
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention()], unread: [mention()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead onMarkAllRead
} }
}); });
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click(); btn.click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
}); });
it('builds a deep-link href to the comment for each notification', async () => { it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toContain('doc-x'); 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

@@ -5,7 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
type Document = components['schemas']['Document']; type Document = components['schemas']['Document'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
interface Props { interface Props {
selectedDocuments?: Document[]; selectedDocuments?: Document[];
@@ -45,8 +45,8 @@ function handleInput() {
try { try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`); const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) { if (res.ok) {
const body: { items: DocumentSearchItem[] } = await res.json(); const body: { items: DocumentListItem[] } = await res.json();
const docs = body.items.map((it) => it.document); const docs = body.items as unknown as Document[];
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id)); results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
} }
} catch { } catch {

View File

@@ -22,7 +22,7 @@ function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true, ok: true,
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) }) json: vi.fn().mockResolvedValue({ items })
}) })
); );
} }
@@ -91,10 +91,7 @@ describe('DocumentMultiSelect — search and select', () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({
items: [ items: [docFactory('d1', 'Already attached'), docFactory('d2', 'Not attached')]
{ document: docFactory('d1', 'Already attached') },
{ document: docFactory('d2', 'Not attached') }
]
}) })
}); });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);

View File

@@ -9,11 +9,11 @@ import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte';
import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte'; import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte'; import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props(); let { item, canWrite = false }: { item: DocumentListItem; canWrite?: boolean } = $props();
const doc = $derived(item.document); const doc = $derived(item);
const titleText = $derived(doc.title || doc.originalFilename); const titleText = $derived(doc.title || doc.originalFilename);
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []); const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
const titleSegments = $derived(applyOffsets(titleText, titleOffsets)); const titleSegments = $derived(applyOffsets(titleText, titleOffsets));

View File

@@ -14,24 +14,17 @@ afterEach(() => {
bulkSelectionStore.clear(); bulkSelectionStore.clear();
}); });
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem { function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
return { return {
document: { id: '1',
id: '1', title: 'Testbrief',
title: 'Testbrief', originalFilename: 'testbrief.pdf',
originalFilename: 'testbrief.pdf', documentDate: '2024-03-15',
status: 'UPLOADED', sender: undefined,
documentDate: '2024-03-15', receivers: [],
sender: null, tags: [],
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: { matchData: {
titleOffsets: [], titleOffsets: [],
senderMatched: false, senderMatched: false,
@@ -55,14 +48,14 @@ describe('DocumentRow title', () => {
}); });
it('falls back to originalFilename when title is null', async () => { it('falls back to originalFilename when title is null', async () => {
const item = makeItem({ document: { ...makeItem().document, title: null } }); const item = makeItem({ title: null as unknown as string });
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument(); await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
}); });
it('renders a mark element for highlighted title offsets', async () => { it('renders a mark element for highlighted title offsets', async () => {
const item = makeItem({ const item = makeItem({
document: { ...makeItem().document, title: 'Brief an Anna' }, title: 'Brief an Anna',
matchData: { matchData: {
titleOffsets: [{ start: 0, length: 5 }], titleOffsets: [{ start: 0, length: 5 }],
senderMatched: false, senderMatched: false,
@@ -109,9 +102,12 @@ describe('DocumentRow snippet', () => {
describe('DocumentRow sender', () => { describe('DocumentRow sender', () => {
it('shows sender display name', async () => { it('shows sender display name', async () => {
const item = makeItem({ const item = makeItem({
document: { sender: {
...makeItem().document, id: 's1',
sender: { id: 's1', displayName: 'Großmutter Maria' } lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
} }
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
@@ -126,9 +122,12 @@ describe('DocumentRow sender', () => {
it('highlights the sender when senderMatched is true', async () => { it('highlights the sender when senderMatched is true', async () => {
const item = makeItem({ const item = makeItem({
document: { sender: {
...makeItem().document, id: 's1',
sender: { id: 's1', displayName: 'Großmutter Maria' } lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
}, },
matchData: { matchData: {
...makeItem().matchData, ...makeItem().matchData,
@@ -142,10 +141,15 @@ describe('DocumentRow sender', () => {
it('highlights a receiver when matchedReceiverIds includes its id', async () => { it('highlights a receiver when matchedReceiverIds includes its id', async () => {
const item = makeItem({ const item = makeItem({
document: { receivers: [
...makeItem().document, {
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }] id: 'r1',
}, lastName: 'Karl',
displayName: 'Onkel Karl',
personType: 'PERSON',
familyMember: false
}
],
matchData: { matchData: {
...makeItem().matchData, ...makeItem().matchData,
matchedReceiverIds: ['r1'] matchedReceiverIds: ['r1']
@@ -162,10 +166,7 @@ describe('DocumentRow sender', () => {
describe('DocumentRow summary', () => { describe('DocumentRow summary', () => {
it('renders the document summary when present', async () => { it('renders the document summary when present', async () => {
const item = makeItem({ const item = makeItem({
document: { summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
...makeItem().document,
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect await expect
@@ -180,7 +181,7 @@ describe('DocumentRow summary', () => {
it('applies summary search-match highlight via summaryOffsets', async () => { it('applies summary search-match highlight via summaryOffsets', async () => {
const item = makeItem({ const item = makeItem({
document: { ...makeItem().document, summary: 'Brief über Menton' }, summary: 'Brief über Menton',
matchData: { matchData: {
...makeItem().matchData, ...makeItem().matchData,
summaryOffsets: [{ start: 11, length: 6 }] summaryOffsets: [{ start: 11, length: 6 }]
@@ -196,25 +197,19 @@ describe('DocumentRow summary', () => {
describe('DocumentRow archive chips', () => { describe('DocumentRow archive chips', () => {
it('renders the archive box chip when set', async () => { it('renders the archive box chip when set', async () => {
const item = makeItem({ const item = makeItem({ archiveBox: 'K3' });
document: { ...makeItem().document, archiveBox: 'K3' }
});
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument(); await expect.element(page.getByText('K3')).toBeInTheDocument();
}); });
it('renders the archive folder chip when set', async () => { it('renders the archive folder chip when set', async () => {
const item = makeItem({ const item = makeItem({ archiveFolder: 'Mappe A' });
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
});
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByText('Mappe A')).toBeInTheDocument(); await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
}); });
it('renders the location chip when meta_location is set', async () => { it('renders the location chip when meta_location is set', async () => {
const item = makeItem({ const item = makeItem({ location: 'Berlin' });
document: { ...makeItem().document, location: 'Berlin' }
});
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument(); await expect.element(page.getByText('Berlin')).toBeInTheDocument();
}); });
@@ -225,10 +220,7 @@ describe('DocumentRow archive chips', () => {
describe('DocumentRow tags', () => { describe('DocumentRow tags', () => {
it('renders tag buttons', async () => { it('renders tag buttons', async () => {
const item = makeItem({ const item = makeItem({
document: { tags: [{ id: 't1', name: 'Familie' }]
...makeItem().document,
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
@@ -236,10 +228,7 @@ describe('DocumentRow tags', () => {
it('navigates to /documents?tag=… on tag click', async () => { it('navigates to /documents?tag=… on tag click', async () => {
const item = makeItem({ const item = makeItem({
document: { tags: [{ id: 't1', name: 'Urlaub & Reise' }]
...makeItem().document,
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
// Tailwind CSS isn't loaded in the vitest-browser client project, so the // Tailwind CSS isn't loaded in the vitest-browser client project, so the
@@ -255,10 +244,7 @@ describe('DocumentRow tags', () => {
it('tag click does not navigate to the document detail page', async () => { it('tag click does not navigate to the document detail page', async () => {
const item = makeItem({ const item = makeItem({
document: { tags: [{ id: 't2', name: 'Familie' }]
...makeItem().document,
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
const before = window.location.href; const before = window.location.href;
@@ -281,7 +267,7 @@ describe('DocumentRow bulk selection checkbox', () => {
}); });
it('checkbox aria-label includes the document title', async () => { it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } }); const item = makeItem({ title: 'Brief an Anna' });
render(DocumentRow, { item, canWrite: true }); render(DocumentRow, { item, canWrite: true });
await expect await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i })) .element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
@@ -289,7 +275,7 @@ describe('DocumentRow bulk selection checkbox', () => {
}); });
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => { it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } }); const item = makeItem({ id: 'doc-42' });
render(DocumentRow, { item, canWrite: true }); render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false); expect(bulkSelectionStore.has('doc-42')).toBe(false);
@@ -300,7 +286,7 @@ describe('DocumentRow bulk selection checkbox', () => {
it('checked state mirrors the store', async () => { it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99'); bulkSelectionStore.add('doc-99');
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } }); const item = makeItem({ id: 'doc-99' });
render(DocumentRow, { item, canWrite: true }); render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked(); await expect.element(page.getByRole('checkbox')).toBeChecked();
}); });

View File

@@ -20,10 +20,31 @@ const { default: DocumentRow } = await import('./DocumentRow.svelte');
afterEach(cleanup); afterEach(cleanup);
const sender = { id: 's1', displayName: 'Anna Schmidt' }; const sender = {
const receiver = { id: 'r1', displayName: 'Bert Meier' }; id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const makeDoc = (overrides: Record<string, unknown> = {}) => ({ const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const baseItem = (overrides: Record<string, unknown> = {}) => ({
id: 'd1', id: 'd1',
title: 'Brief 1923', title: 'Brief 1923',
originalFilename: 'b.pdf', originalFilename: 'b.pdf',
@@ -31,20 +52,14 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
sender, sender,
receivers: [receiver], receivers: [receiver],
tags: [], tags: [],
thumbnailUrl: null, summary: undefined,
contentType: 'application/pdf', archiveBox: undefined,
summary: null, archiveFolder: undefined,
archiveBox: null, location: undefined,
archiveFolder: null, matchData: emptyMatchData,
location: null,
...overrides
});
const baseItem = (docOverrides: Record<string, unknown> = {}) => ({
document: makeDoc(docOverrides),
matchData: null,
completionPercentage: 0, completionPercentage: 0,
contributors: [] contributors: [],
...overrides
}); });
describe('DocumentRow', () => { describe('DocumentRow', () => {
@@ -121,12 +136,9 @@ describe('DocumentRow', () => {
it('renders the snippet when matchData provides a transcriptionSnippet', async () => { it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
render(DocumentRow, { render(DocumentRow, {
props: { props: {
item: { item: baseItem({
document: makeDoc(), matchData: { ...emptyMatchData, transcriptionSnippet: 'Hello world snippet' }
matchData: { transcriptionSnippet: 'Hello world snippet' }, })
completionPercentage: 50,
contributors: []
}
} }
}); });

View File

@@ -2068,12 +2068,20 @@ export interface components {
}; };
ImportStatus: { ImportStatus: {
/** @enum {string} */ /** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED"; state: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode?: string; statusCode: string;
/** Format: int32 */ /** Format: int32 */
processed?: number; processed: number;
skippedFiles: components["schemas"]["SkippedFile"][];
/** Format: date-time */ /** Format: date-time */
startedAt?: string; startedAt?: string;
/** Format: int32 */
skipped?: number;
};
SkippedFile: {
filename: string;
/** @enum {string} */
reason: "INVALID_FILENAME_PATH_TRAVERSAL" | "INVALID_PDF_SIGNATURE" | "FILE_READ_ERROR" | "ALREADY_EXISTS" | "S3_UPLOAD_FAILED";
}; };
BackfillStatus: { BackfillStatus: {
/** @enum {string} */ /** @enum {string} */
@@ -2197,10 +2205,10 @@ export interface components {
totalStories: number; totalStories: number;
}; };
PersonSummaryDTO: { PersonSummaryDTO: {
title?: string;
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
displayName?: string; displayName?: string;
title?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
/** Format: int64 */ /** Format: int64 */
@@ -2307,14 +2315,14 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
pageable?: components["schemas"]["PageableObject"]; pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
size?: number; size?: number;
content?: components["schemas"]["NotificationDTO"][]; content?: components["schemas"]["NotificationDTO"][];
/** Format: int32 */ /** Format: int32 */
number?: number; number?: number;
sort?: components["schemas"]["SortObject"]; sort?: components["schemas"]["SortObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
numberOfElements?: number; numberOfElements?: number;
empty?: boolean; empty?: boolean;
@@ -2380,15 +2388,28 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
}; };
DocumentSearchItem: { DocumentListItem: {
document: components["schemas"]["Document"]; /** Format: uuid */
matchData: components["schemas"]["SearchMatchData"]; id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */ /** Format: int32 */
completionPercentage: number; completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][]; contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
}; };
DocumentSearchResult: { DocumentSearchResult: {
items: components["schemas"]["DocumentSearchItem"][]; items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */ /** Format: int64 */
totalElements: number; totalElements: number;
/** Format: int32 */ /** Format: int32 */

View File

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

View File

@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte'; import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() })); const gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/forms', () => ({ vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
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 mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({ vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() { get unreadCount() {
return mockNotificationList.value.length; return mockNotificationList.value.length;
}, },
optimisticMarkRead: vi.fn(), markRead: mockMarkRead,
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined), fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(), init: vi.fn(),
destroy: vi.fn() destroy: vi.fn(),
markAllRead: vi.fn()
} }
})); }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = []; mockNotificationList.value = [];
}); });
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides ...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', () => { describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => { it('bell button has cursor-pointer class', async () => {
render(NotificationBell); render(NotificationBell);
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); 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"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = { type Props = {
notifications: NotificationItem[]; notifications: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (notification: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
onClose: () => void; onClose: () => void;
}; };
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props(); let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
function handleViewAll() { function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition onClose(); // close first — avoids stale dropdown during navigation transition
@@ -35,35 +31,16 @@ function handleViewAll() {
{m.notification_bell_label()} {m.notification_bell_label()}
</span> </span>
{#if notifications.length > 0} {#if notifications.length > 0}
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" onclick={onMarkAllRead}
use:enhance={() => { class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
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 {m.notification_mark_all_read()}
type="submit" </button>
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{/if} {/if}
</div> </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 --> <!-- Notification list -->
{#if notifications.length === 0} {#if notifications.length === 0}
<!-- Empty state --> <!-- Empty state -->
@@ -89,93 +66,67 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto"> <ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
<li> <li>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" onclick={() => onMarkRead(notification)}
class="contents" 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
use:enhance={() => { {!notification.read ? 'bg-accent-bg/20' : ''}"
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
)
);
}
};
}}
> >
<input type="hidden" name="notificationId" value={notification.id} /> <!-- Type icon -->
<button <span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
type="submit" {#if notification.type === 'REPLY'}
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 <!-- Reply icon -->
{!notification.read ? 'bg-accent-bg/20' : ''}" <svg
> xmlns="http://www.w3.org/2000/svg"
<!-- Type icon --> class="h-4 w-4"
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true"> fill="none"
{#if notification.type === 'REPLY'} viewBox="0 0 24 24"
<!-- Reply icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
stroke-width="2" />
> </svg>
<path {:else}
stroke-linecap="round" <!-- Mention icon -->
stroke-linejoin="round" <svg
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" xmlns="http://www.w3.org/2000/svg"
/> class="h-4 w-4"
</svg> fill="none"
{:else} viewBox="0 0 24 24"
<!-- Mention icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" 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"
stroke-width="2" />
> </svg>
<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>
{/if} {/if}
</button> </span>
</form>
<!-- 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> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -6,38 +6,9 @@ import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); 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(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
}); });
const makeNotification = (overrides: Record<string, unknown> = {}) => ({ const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })], notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true }) makeNotification({ id: 'n2', read: true })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1); expect(unreadDots.length).toBe(1);
}); });
it('each notification row is wrapped in a form posting to the dismiss action', async () => { it('calls onMarkRead with the notification when an item is clicked', async () => {
render(NotificationDropdown, { const onMarkRead = vi.fn();
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();
const n = makeNotification({ id: 'n42', actorName: 'Anna' }); const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [n], notifications: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click(); 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, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead,
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,
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /alle gelesen/i }).click(); await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).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();
}); });
it('calls onClose when the view-all button is clicked', async () => { it('calls onClose when the view-all button is clicked', async () => {
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => { it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = []; const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close')); const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => { vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
callOrder.push('goto');
return Promise.resolve();
});
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })], notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }), makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' }) makeNotification({ id: 'n2', actorName: 'Second' })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]'); const items = document.querySelectorAll('button[type="button"]');
expect(forms.length).toBe(2); // At least 2 items + mark-all button
}); expect(items.length).toBeGreaterThanOrEqual(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');
}); });
}); });

View File

@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1); expect(notificationStore.unreadCount).toBe(1);
}); });
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { it('markAllRead resets unreadCount', async () => {
notificationStore.init(); mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const notification = makeNotification({ id: 'sse-1', read: false }); await notificationStore.markAllRead();
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
notificationStore.optimisticMarkRead('sse-1'); expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0); 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 { async function markRead(notification: NotificationItem): Promise<void> {
const notification = notifications.find((n) => n.id === id); if (!notification.read) {
if (notification && !notification.read) { try {
notification.read = true; await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
unreadCount = Math.max(0, unreadCount - 1); notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
} }
} }
function optimisticMarkAllRead(): void { async function markAllRead(): Promise<void> {
for (const n of notifications) { try {
n.read = true; 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 { function init(): void {
@@ -114,8 +123,8 @@ export const notificationStore = {
}, },
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
optimisticMarkRead, markRead,
optimisticMarkAllRead, markAllRead,
init, init,
destroy destroy
}; };

View File

@@ -5,7 +5,7 @@ import DocumentRow from '$lib/document/DocumentRow.svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE'; type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE';
@@ -17,7 +17,7 @@ let {
q = '', q = '',
sort = 'DATE' sort = 'DATE'
}: { }: {
items: DocumentSearchItem[]; items: DocumentListItem[];
canWrite: boolean; canWrite: boolean;
error?: string | null; error?: string | null;
total?: number; total?: number;
@@ -31,10 +31,10 @@ const groups = $derived.by(() => {
return groupByYear(items); return groupByYear(items);
}); });
function groupByYear(docItems: DocumentSearchItem[]) { function groupByYear(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>(); const map = new SvelteMap<string, DocumentListItem[]>();
for (const item of docItems) { for (const item of docItems) {
const label = item.document.documentDate?.substring(0, 4) ?? m.docs_group_undated(); const label = item.documentDate?.substring(0, 4) ?? m.docs_group_undated();
const bucket = map.get(label); const bucket = map.get(label);
if (bucket) bucket.push(item); if (bucket) bucket.push(item);
else map.set(label, [item]); else map.set(label, [item]);
@@ -42,10 +42,10 @@ function groupByYear(docItems: DocumentSearchItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
} }
function groupBySender(docItems: DocumentSearchItem[]) { function groupBySender(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>(); const map = new SvelteMap<string, DocumentListItem[]>();
for (const item of docItems) { for (const item of docItems) {
const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender(); const label = item.sender?.displayName ?? m.docs_group_unknown_sender();
const bucket = map.get(label); const bucket = map.get(label);
if (bucket) bucket.push(item); if (bucket) bucket.push(item);
else map.set(label, [item]); else map.set(label, [item]);
@@ -53,10 +53,10 @@ function groupBySender(docItems: DocumentSearchItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
} }
function groupByReceiver(docItems: DocumentSearchItem[]) { function groupByReceiver(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>(); const map = new SvelteMap<string, DocumentListItem[]>();
for (const item of docItems) { for (const item of docItems) {
const receivers = item.document.receivers ?? []; const receivers = item.receivers ?? [];
const labels = const labels =
receivers.length > 0 receivers.length > 0
? receivers.map((r) => r.displayName) ? receivers.map((r) => r.displayName)
@@ -99,7 +99,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
> >
</div> </div>
<ul class="divide-y divide-line"> <ul class="divide-y divide-line">
{#each group.items as item (group.label + '-' + item.document.id)} {#each group.items as item (group.label + '-' + item.id)}
<DocumentRow item={item} canWrite={canWrite} /> <DocumentRow item={item} canWrite={canWrite} />
{/each} {/each}
</ul> </ul>

View File

@@ -8,24 +8,17 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => cleanup()); afterEach(() => cleanup());
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem { function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
return { return {
document: { id: '1',
id: '1', title: 'Testbrief',
title: 'Testbrief', originalFilename: 'testbrief.pdf',
originalFilename: 'testbrief.pdf', documentDate: '2024-03-15',
status: 'UPLOADED', sender: undefined,
documentDate: '2024-03-15', receivers: [],
sender: undefined, tags: [],
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: { matchData: {
titleOffsets: [], titleOffsets: [],
senderMatched: false, senderMatched: false,
@@ -75,8 +68,8 @@ describe('DocumentList empty state', () => {
describe('DocumentList year grouping', () => { describe('DocumentList year grouping', () => {
it('groups documents by year into separate cards', async () => { it('groups documents by year into separate cards', async () => {
const items = [ const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }), makeItem({ id: '1', documentDate: '1923-04-12' }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } }) makeItem({ id: '2', documentDate: '1965-08-03' })
]; ];
render(DocumentList, { ...baseProps, items, total: 2 }); render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card'); const groupCards = page.getByTestId('group-card');
@@ -85,17 +78,15 @@ describe('DocumentList year grouping', () => {
}); });
it('uses undated label for items with no documentDate', async () => { it('uses undated label for items with no documentDate', async () => {
const items = [ const items = [makeItem({ id: '1', documentDate: undefined })];
makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } })
];
render(DocumentList, { ...baseProps, items, total: 1 }); render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByText('Undatiert')).toBeInTheDocument(); await expect.element(page.getByText('Undatiert')).toBeInTheDocument();
}); });
it('single year renders one group-card', async () => { it('single year renders one group-card', async () => {
const items = [ const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }), makeItem({ id: '1', documentDate: '1938-01-01' }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } }) makeItem({ id: '2', documentDate: '1938-06-15' })
]; ];
render(DocumentList, { ...baseProps, items, total: 2 }); render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card'); const groupCards = page.getByTestId('group-card');
@@ -108,9 +99,7 @@ describe('DocumentList year grouping', () => {
describe('DocumentList sort fallback', () => { describe('DocumentList sort fallback', () => {
it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => { it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => {
const items = [ const items = [makeItem({ id: '1', documentDate: '2024-03-15' })];
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '2024-03-15' } })
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' });
await expect await expect
.element(page.getByTestId('group-header').filter({ hasText: '2024' })) .element(page.getByTestId('group-header').filter({ hasText: '2024' }))
@@ -124,29 +113,23 @@ describe('DocumentList sender grouping', () => {
it('groups by sender displayName when sort is SENDER', async () => { it('groups by sender displayName when sort is SENDER', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { id: '1',
...makeItem().document, sender: {
id: '1', id: 's1',
sender: { lastName: 'Mustermann',
id: 's1', displayName: 'Max Mustermann',
lastName: 'Mustermann', personType: 'PERSON',
displayName: 'Max Mustermann', familyMember: false
personType: 'PERSON',
familyMember: false
}
} }
}), }),
makeItem({ makeItem({
document: { id: '2',
...makeItem().document, sender: {
id: '2', id: 's2',
sender: { lastName: 'Musterfrau',
id: 's2', displayName: 'Anna Musterfrau',
lastName: 'Musterfrau', personType: 'PERSON',
displayName: 'Anna Musterfrau', familyMember: false
personType: 'PERSON',
familyMember: false
}
} }
}) })
]; ];
@@ -167,10 +150,7 @@ describe('DocumentList sender grouping', () => {
personType: 'PERSON' as const, personType: 'PERSON' as const,
familyMember: false familyMember: false
}; };
const items = [ const items = [makeItem({ id: '1', sender }), makeItem({ id: '2', sender })];
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
makeItem({ document: { ...makeItem().document, id: '2', sender } })
];
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
const cards = page.getByTestId('group-card'); const cards = page.getByTestId('group-card');
await expect.element(cards.first()).toBeInTheDocument(); await expect.element(cards.first()).toBeInTheDocument();
@@ -178,7 +158,7 @@ describe('DocumentList sender grouping', () => {
}); });
it('places items with no sender under fallback label', async () => { it('places items with no sender under fallback label', async () => {
const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })]; const items = [makeItem({ id: '1', sender: undefined })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument(); await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument();
}); });
@@ -190,19 +170,16 @@ describe('DocumentList receiver grouping', () => {
it('groups by receiver displayName when sort is RECEIVER', async () => { it('groups by receiver displayName when sort is RECEIVER', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { id: '1',
...makeItem().document, receivers: [
id: '1', {
receivers: [ id: 'r1',
{ lastName: 'Brandt',
id: 'r1', displayName: 'Felix Brandt',
lastName: 'Brandt', personType: 'PERSON',
displayName: 'Felix Brandt', familyMember: false
personType: 'PERSON', }
familyMember: false ]
}
]
}
}) })
]; ];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -214,27 +191,24 @@ describe('DocumentList receiver grouping', () => {
it('duplicates a document into each receiver group', async () => { it('duplicates a document into each receiver group', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { id: '1',
...makeItem().document, title: 'Rundbriefchen',
id: '1', receivers: [
title: 'Rundbriefchen', {
receivers: [ id: 'r1',
{ lastName: 'Brandt',
id: 'r1', displayName: 'Felix Brandt',
lastName: 'Brandt', personType: 'PERSON',
displayName: 'Felix Brandt', familyMember: false
personType: 'PERSON', },
familyMember: false {
}, id: 'r2',
{ lastName: 'Meier',
id: 'r2', displayName: 'Hans Meier',
lastName: 'Meier', personType: 'PERSON',
displayName: 'Hans Meier', familyMember: false
personType: 'PERSON', }
familyMember: false ]
}
]
}
}) })
]; ];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -249,7 +223,7 @@ describe('DocumentList receiver grouping', () => {
}); });
it('places items with no receivers under fallback label', async () => { it('places items with no receivers under fallback label', async () => {
const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })]; const items = [makeItem({ id: '1', receivers: [] })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument(); await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument();
}); });
@@ -261,7 +235,7 @@ describe('DocumentList DocumentRow delegation', () => {
it('shows transcription snippet when matchData has one', async () => { it('shows transcription snippet when matchData has one', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { ...makeItem().document, id: 'doc1' }, id: 'doc1',
matchData: { matchData: {
transcriptionSnippet: 'Er schrieb einen langen Brief', transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [], titleOffsets: [],
@@ -278,7 +252,7 @@ describe('DocumentList DocumentRow delegation', () => {
}); });
it('does not render snippet when matchData has no transcription snippet', async () => { it('does not render snippet when matchData has no transcription snippet', async () => {
const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })]; const items = [makeItem({ id: 'doc1' })];
render(DocumentList, { ...baseProps, items, total: 1 }); render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument(); await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
}); });
@@ -286,7 +260,8 @@ describe('DocumentList DocumentRow delegation', () => {
it('renders mark for title highlight when titleOffsets present', async () => { it('renders mark for title highlight when titleOffsets present', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' }, id: 'doc1',
title: 'Brief an Anna',
matchData: { matchData: {
titleOffsets: [{ start: 0, length: 5 }], // "Brief" titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false, senderMatched: false,

View File

@@ -20,29 +20,46 @@ const { default: DocumentList } = await import('./DocumentList.svelte');
afterEach(cleanup); afterEach(cleanup);
const sender = { id: 's1', displayName: 'Anna Schmidt' }; const sender = {
const receiver = { id: 'r1', displayName: 'Bert Meier' }; id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const makeItem = (overrides: Record<string, unknown> = {}) => ({ const makeItem = (overrides: Record<string, unknown> = {}) => ({
document: { id: 'd1',
id: 'd1', title: 'Brief 1923',
title: 'Brief 1923', originalFilename: 'b.pdf',
originalFilename: 'b.pdf', documentDate: '1923-04-15',
documentDate: '1923-04-15', sender,
sender, receivers: [receiver],
receivers: [receiver], tags: [],
tags: [], summary: undefined,
thumbnailUrl: null, archiveBox: undefined,
contentType: 'application/pdf', archiveFolder: undefined,
summary: null, location: undefined,
archiveBox: null, matchData: emptyMatchData,
archiveFolder: null,
location: null,
...overrides
},
matchData: null,
completionPercentage: 0, completionPercentage: 0,
contributors: [] contributors: [],
...overrides
}); });
describe('DocumentList', () => { describe('DocumentList', () => {
@@ -87,8 +104,26 @@ describe('DocumentList', () => {
render(DocumentList, { render(DocumentList, {
props: { props: {
items: [ items: [
makeItem({ id: 'd1', sender: { id: 's1', displayName: 'Anna Schmidt' } }), makeItem({
makeItem({ id: 'd2', sender: { id: 's2', displayName: 'Bert Meier' } }) id: 'd1',
sender: {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON',
familyMember: false
}
}),
makeItem({
id: 'd2',
sender: {
id: 's2',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON',
familyMember: false
}
})
], ],
canWrite: false, canWrite: false,
sort: 'SENDER' as const sort: 'SENDER' as const

View File

@@ -1,6 +1,4 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components, operations } from '$lib/generated/api'; import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
loadError 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 displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
const isEmpty = $derived(displayFeed.length === 0); const isEmpty = $derived(displayFeed.length === 0);
@@ -100,11 +108,7 @@ function retry() {
{#if data.loadError === 'activity'} {#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} /> <ChronikErrorCard onRetry={retry} />
{:else} {:else}
<ChronikFuerDichBox <ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
unread={unread}
optimisticMarkRead={notificationStore.optimisticMarkRead}
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
/>
<div class="mt-6"> <div class="mt-6">
<ChronikFilterPills value={data.filter} onChange={onFilterChange} /> <ChronikFilterPills value={data.filter} onChange={onFilterChange} />

View File

@@ -1,10 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load, actions } from './+page.server'; import { load } from './+page.server';
const mockApi = { const mockApi = {
GET: vi.fn(), GET: vi.fn()
PATCH: vi.fn(),
POST: vi.fn()
}; };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({
@@ -175,84 +173,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
expect(call[1].params.query.kinds).toHaveLength(2); 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

@@ -20,7 +20,7 @@ async function resolvePersonName(
} }
} }
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const; const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
type ValidSort = (typeof VALID_SORTS)[number]; type ValidSort = (typeof VALID_SORTS)[number];
@@ -77,7 +77,7 @@ export async function load({ url, fetch }) {
]); ]);
} catch { } catch {
return { return {
items: [] as DocumentSearchItem[], items: [] as DocumentListItem[],
totalElements: 0, totalElements: 0,
pageNumber: 0, pageNumber: 0,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -108,7 +108,7 @@ export async function load({ url, fetch }) {
: null; : null;
return { return {
items: (result.data?.items ?? []) as DocumentSearchItem[], items: (result.data?.items ?? []) as DocumentListItem[],
totalElements: result.data?.totalElements ?? 0, totalElements: result.data?.totalElements ?? 0,
pageNumber: result.data?.pageNumber ?? page, pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE, pageSize: result.data?.pageSize ?? PAGE_SIZE,

View File

@@ -140,15 +140,12 @@ describe('documents/+ page', () => {
data: baseData({ data: baseData({
items: [ items: [
{ {
document: { id: 'd1',
id: 'd1', title: 'Brief 1899',
title: 'Brief 1899', documentDate: '1899-04-14',
status: 'TRANSCRIBED', originalFilename: 'b1.pdf',
documentDate: '1899-04-14', receivers: [],
summary: '', tags: [],
originalFilename: 'b1.pdf',
receivers: []
},
matchData: { matchData: {
titleOffsets: [], titleOffsets: [],
senderMatched: false, senderMatched: false,