Compare commits
4 Commits
main
...
3311dc29ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3311dc29ae | ||
|
|
67ae494d18 | ||
|
|
782f845dc9 | ||
|
|
1a70a3db6d |
@@ -13,6 +13,11 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
@@ -62,6 +67,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@RequestMapping("/api/documents")
|
@RequestMapping("/api/documents")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@Validated
|
||||||
public class DocumentController {
|
public class DocumentController {
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
@@ -252,14 +258,20 @@ public class DocumentController {
|
|||||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
||||||
|
// @Max on page guards against overflow when pageable.getOffset() is computed
|
||||||
|
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
||||||
|
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
||||||
|
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
||||||
|
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||||
}
|
}
|
||||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
|
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TRAINING LABELS ---
|
// --- TRAINING LABELS ---
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -8,9 +9,30 @@ public record DocumentSearchResult(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<DocumentSearchItem> items,
|
List<DocumentSearchItem> items,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
long total
|
long totalElements,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int pageNumber,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int pageSize,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int totalPages
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* 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) {
|
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||||
return new DocumentSearchResult(items, items.size());
|
int size = items.size();
|
||||||
|
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||||
|
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
|
|||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -355,7 +357,7 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
|
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
|
|
||||||
@@ -376,15 +378,18 @@ public class DocumentService {
|
|||||||
.and(hasTagPartial(tagQ))
|
.and(hasTagPartial(tagQ))
|
||||||
.and(hasStatus(status));
|
.and(hasStatus(status));
|
||||||
|
|
||||||
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
|
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
|
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||||
|
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||||
|
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
if (sort == DocumentSort.SENDER) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||||
return buildResult(sortBySender(results, dir), text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// RELEVANCE: default when text present and no explicit sort given
|
// RELEVANCE: default when text present and no explicit sort given
|
||||||
@@ -397,15 +402,26 @@ public class DocumentService {
|
|||||||
.sorted(Comparator.comparingInt(
|
.sorted(Comparator.comparingInt(
|
||||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResult(sorted, text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
Sort springSort = resolveSort(sort, dir);
|
// Fast path — push sort + paging into the DB and enrich only the returned slice.
|
||||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
||||||
return buildResult(results, text);
|
Page<Document> page = documentRepository.findAll(spec, pageRequest);
|
||||||
|
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentSearchResult buildResult(List<Document> documents, String text) {
|
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||||
|
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||||
|
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||||
|
return sorted.subList(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
|
||||||
|
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DocumentSearchItem> 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);
|
||||||
|
|
||||||
@@ -413,14 +429,12 @@ 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);
|
||||||
|
|
||||||
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem(
|
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||||
doc,
|
doc,
|
||||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
return DocumentSearchResult.of(items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -79,13 +79,13 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
void search_withStatusParam_passesItToService() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -112,12 +112,12 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseContainsTotalCount() throws Exception {
|
void search_responseContainsTotalCount() throws Exception {
|
||||||
when(documentService.searchDocuments(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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.total").value(0))
|
.andExpect(jsonPath("$.totalElements").value(0))
|
||||||
.andExpect(jsonPath("$.items").isArray());
|
.andExpect(jsonPath("$.items").isArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.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()))
|
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 DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
@@ -143,6 +143,70 @@ class DocumentControllerTest {
|
|||||||
.value("Er schrieb einen langen Brief"));
|
.value("Er schrieb einen langen Brief"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_responseExposesPagingFields() throws Exception {
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.pageNumber").exists())
|
||||||
|
.andExpect(jsonPath("$.pageSize").exists())
|
||||||
|
.andExpect(jsonPath("$.totalPages").exists())
|
||||||
|
.andExpect(jsonPath("$.totalElements").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenSizeExceedsMax() throws Exception {
|
||||||
|
// Locks @Validated on the controller — removing it silently reopens the
|
||||||
|
// DoS window where a client could request all 1500 docs + enrichment.
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("size", "101"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenSizeBelowMin() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("size", "0"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenPageNegative() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenPageAboveMax() throws Exception {
|
||||||
|
// Guards against page * size overflow into negative SQL OFFSET
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_passesPageRequestToService() throws Exception {
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||||
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
||||||
|
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
||||||
|
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||||
|
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -24,10 +25,43 @@ class DocumentSearchResultTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void of_total_equals_list_size() {
|
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
DocumentSearchResult result = DocumentSearchResult.of(
|
||||||
|
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||||
|
|
||||||
assertThat(result.total()).isEqualTo(2L);
|
assertThat(result.totalElements()).isEqualTo(2L);
|
||||||
|
assertThat(result.pageNumber()).isZero();
|
||||||
|
assertThat(result.pageSize()).isEqualTo(2);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_empty_shortcut_has_zero_totalPages() {
|
||||||
|
DocumentSearchResult result = DocumentSearchResult.of(List.of());
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isZero();
|
||||||
|
assertThat(result.totalPages()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||||
|
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||||
|
|
||||||
|
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paged_factory_totalPages_rounds_up_on_remainder() {
|
||||||
|
DocumentSearchResult result =
|
||||||
|
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
|
||||||
|
|
||||||
|
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,9 +87,18 @@ class DocumentSearchResultTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
||||||
assertThat(schema).isNotNull();
|
assertThat(schema).isNotNull();
|
||||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||||
|
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
|
||||||
|
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
|
||||||
|
assertThat(schema).as(name + " must have @Schema").isNotNull();
|
||||||
|
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import org.raddatz.familienarchiv.dto.DocumentSort;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -51,12 +52,12 @@ class DocumentServiceSortTest {
|
|||||||
|
|
||||||
// FTS returns id1 first (higher rank), id2 second
|
// FTS returns id1 first (higher rank), id2 second
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of(newer, older));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
@@ -78,7 +79,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
// Expect: rank order restored (id1 first)
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
@@ -97,7 +98,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1));
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
"Brief", null, null, null, null, null, null, null, null, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1323,26 +1323,124 @@ class DocumentServiceTest {
|
|||||||
assertThat(result).isNull();
|
assertThat(result).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── searchDocuments — pagination ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
|
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_fastPath_propagatesPageableToDatabase() {
|
||||||
|
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(3, 25));
|
||||||
|
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||||
|
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||||
|
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
|
||||||
|
// The service MUST report the full match count from Page.getTotalElements(),
|
||||||
|
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
|
||||||
|
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isZero();
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
|
||||||
|
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||||
|
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||||
|
// back with totalElements = 120.
|
||||||
|
List<Document> all = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < 120; i++) {
|
||||||
|
Person p = Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("F" + i)
|
||||||
|
.lastName(String.format("L%03d", i))
|
||||||
|
.build();
|
||||||
|
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||||
|
}
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(all);
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3);
|
||||||
|
assertThat(result.items()).hasSize(50);
|
||||||
|
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||||
|
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
|
||||||
|
// Guards the JPA edge case where page * size > totalElements.
|
||||||
|
// Must not throw, must return empty content + correct totalElements.
|
||||||
|
List<Document> all = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
|
||||||
|
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||||
|
}
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(all);
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).isEmpty();
|
||||||
|
assertThat(result.totalElements()).isEqualTo(30L);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
|
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
|
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||||
@@ -1418,7 +1516,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(withSender, noSender));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
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(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||||
@@ -1438,7 +1536,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
@@ -1460,7 +1558,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
// 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(item -> item.document().getTitle())
|
||||||
@@ -1482,7 +1580,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
@@ -1492,11 +1590,12 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, null, null, null);
|
null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -1515,7 +1614,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
||||||
|
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||||
|
|||||||
@@ -806,5 +806,9 @@
|
|||||||
"chronik_load_more": "Mehr laden",
|
"chronik_load_more": "Mehr laden",
|
||||||
"chronik_loading": "Lädt …",
|
"chronik_loading": "Lädt …",
|
||||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
||||||
"chronik_view_all": "Alle Aktivitäten →"
|
"chronik_view_all": "Alle Aktivitäten →",
|
||||||
|
"pagination_prev": "Zurück",
|
||||||
|
"pagination_next": "Weiter",
|
||||||
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
|
"pagination_nav_label": "Seitennavigation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,9 @@
|
|||||||
"chronik_load_more": "Load more",
|
"chronik_load_more": "Load more",
|
||||||
"chronik_loading": "Loading …",
|
"chronik_loading": "Loading …",
|
||||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
"chronik_load_more_announcement": "{count} more entries loaded",
|
||||||
"chronik_view_all": "All activity →"
|
"chronik_view_all": "All activity →",
|
||||||
|
"pagination_prev": "Previous",
|
||||||
|
"pagination_next": "Next",
|
||||||
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
|
"pagination_nav_label": "Pagination"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,5 +806,9 @@
|
|||||||
"chronik_load_more": "Cargar más",
|
"chronik_load_more": "Cargar más",
|
||||||
"chronik_loading": "Cargando …",
|
"chronik_loading": "Cargando …",
|
||||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
||||||
"chronik_view_all": "Todas las actividades →"
|
"chronik_view_all": "Todas las actividades →",
|
||||||
|
"pagination_prev": "Anterior",
|
||||||
|
"pagination_next": "Siguiente",
|
||||||
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
|
"pagination_nav_label": "Paginación"
|
||||||
}
|
}
|
||||||
|
|||||||
58
frontend/src/lib/components/Pagination.svelte
Normal file
58
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 0-indexed current page. */
|
||||||
|
page: number;
|
||||||
|
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
|
||||||
|
totalPages: number;
|
||||||
|
/** Given a 0-indexed page number, returns the href the link should point at. */
|
||||||
|
makeHref: (page: number) => string;
|
||||||
|
/** Optional override for the outer `<nav>`'s aria-label. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
||||||
|
|
||||||
|
const hasPrev = $derived(page > 0);
|
||||||
|
const hasNext = $derived(page < totalPages - 1);
|
||||||
|
const linkBase =
|
||||||
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<nav
|
||||||
|
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||||
|
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
data-testid="pagination-prev"
|
||||||
|
aria-label={m.pagination_prev()}
|
||||||
|
aria-disabled={!hasPrev}
|
||||||
|
href={hasPrev ? makeHref(page - 1) : undefined}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
{m.pagination_prev()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="pagination-page-label"
|
||||||
|
aria-current="page"
|
||||||
|
class="font-sans text-sm text-ink-2"
|
||||||
|
>
|
||||||
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
data-testid="pagination-next"
|
||||||
|
aria-label={m.pagination_next()}
|
||||||
|
aria-disabled={!hasNext}
|
||||||
|
href={hasNext ? makeHref(page + 1) : undefined}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
{m.pagination_next()}
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
84
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
84
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import Pagination from './Pagination.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeHref = (p: number) => `/documents?page=${p}`;
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('renders the page-of-total label for the current page', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
|
||||||
|
await expect.element(label).toHaveTextContent(/10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the current page label with aria-current="page"', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables prev on page 0 (no href, aria-disabled="true")', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect.element(prev).not.toHaveAttribute('href');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const next = page.getByTestId('pagination-next');
|
||||||
|
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables next on the last page (no href, aria-disabled="true")', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const next = page.getByTestId('pagination-next');
|
||||||
|
await expect.element(next).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect.element(next).not.toHaveAttribute('href');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls makeHref with p-1 and p+1', async () => {
|
||||||
|
const spy = vi.fn(makeHref);
|
||||||
|
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
|
||||||
|
|
||||||
|
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
|
||||||
|
expect(calls).toContain(2);
|
||||||
|
expect(calls).toContain(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
|
||||||
|
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect
|
||||||
|
.element(prev.getByText('«', { exact: true }))
|
||||||
|
.toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev and next have min 44px touch targets', async () => {
|
||||||
|
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1921,7 +1921,13 @@ export interface components {
|
|||||||
DocumentSearchResult: {
|
DocumentSearchResult: {
|
||||||
items: components["schemas"]["DocumentSearchItem"][];
|
items: components["schemas"]["DocumentSearchItem"][];
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
total: number;
|
totalElements: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageSize: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
totalPages: number;
|
||||||
};
|
};
|
||||||
MatchOffset: {
|
MatchOffset: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -4032,6 +4038,16 @@ export interface operations {
|
|||||||
dir?: string;
|
dir?: string;
|
||||||
/** @description Tag operator: AND (default) or OR */
|
/** @description Tag operator: AND (default) or OR */
|
||||||
tagOp?: string;
|
tagOp?: string;
|
||||||
|
/**
|
||||||
|
* @description Page number (0-indexed)
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
page?: number;
|
||||||
|
/**
|
||||||
|
* @description Page size (max 100)
|
||||||
|
* @default 50
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type ValidSort = (typeof VALID_SORTS)[number];
|
|||||||
const VALID_DIRS = ['asc', 'desc'] as const;
|
const VALID_DIRS = ['asc', 'desc'] as const;
|
||||||
type ValidDir = (typeof VALID_DIRS)[number];
|
type ValidDir = (typeof VALID_DIRS)[number];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ url, fetch }) {
|
||||||
const q = url.searchParams.get('q') || '';
|
const q = url.searchParams.get('q') || '';
|
||||||
const from = url.searchParams.get('from') || '';
|
const from = url.searchParams.get('from') || '';
|
||||||
@@ -27,6 +29,7 @@ export async function load({ url, fetch }) {
|
|||||||
: 'desc';
|
: 'desc';
|
||||||
const tagQ = url.searchParams.get('tagQ') || '';
|
const tagQ = url.searchParams.get('tagQ') || '';
|
||||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||||
|
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
@@ -44,14 +47,19 @@ export async function load({ url, fetch }) {
|
|||||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||||
sort,
|
sort,
|
||||||
dir: dir || undefined
|
dir: dir || undefined,
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
items: [] as DocumentSearchItem[],
|
items: [] as DocumentSearchItem[],
|
||||||
total: 0,
|
totalElements: 0,
|
||||||
|
pageNumber: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
totalPages: 0,
|
||||||
q,
|
q,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -77,7 +85,10 @@ export async function load({ url, fetch }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
||||||
total: result.data?.total ?? 0,
|
totalElements: result.data?.totalElements ?? 0,
|
||||||
|
pageNumber: result.data?.pageNumber ?? page,
|
||||||
|
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
||||||
|
totalPages: result.data?.totalPages ?? 0,
|
||||||
q,
|
q,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { untrack } from 'svelte';
|
|||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||||
import DocumentList from '../DocumentList.svelte';
|
import DocumentList from '../DocumentList.svelte';
|
||||||
|
import Pagination from '$lib/components/Pagination.svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -35,6 +36,12 @@ let showAdvanced = $state(untrack(hasAdvancedFilters));
|
|||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
||||||
|
* not carried over — any filter change implicitly resets back to page 0, which
|
||||||
|
* is the expected behaviour. For page-only navigation use {@link buildPageHref}
|
||||||
|
* instead, which preserves every filter from the server `data`.
|
||||||
|
*/
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = new SvelteURLSearchParams();
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
@@ -50,6 +57,29 @@ function triggerSearch() {
|
|||||||
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the href for a Pagination prev/next link. Preserves every current
|
||||||
|
* filter param and only updates `page`. Uses a normal <a href> (not goto)
|
||||||
|
* so SvelteKit's default scroll restoration brings the user to the top of
|
||||||
|
* the new slice — the expected behaviour for page navigation.
|
||||||
|
*/
|
||||||
|
function buildPageHref(targetPage: number): string {
|
||||||
|
const params = new SvelteURLSearchParams();
|
||||||
|
if (data.q) params.set('q', data.q);
|
||||||
|
if (data.from) params.set('from', data.from);
|
||||||
|
if (data.to) params.set('to', data.to);
|
||||||
|
if (data.senderId) params.set('senderId', data.senderId);
|
||||||
|
if (data.receiverId) params.set('receiverId', data.receiverId);
|
||||||
|
(data.tags || []).forEach((t: string) => params.append('tag', t));
|
||||||
|
if (data.sort) params.set('sort', data.sort);
|
||||||
|
if (data.dir) params.set('dir', data.dir);
|
||||||
|
if (data.tagQ) params.set('tagQ', data.tagQ);
|
||||||
|
if (data.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||||
|
if (targetPage > 0) params.set('page', String(targetPage));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `/documents?${qs}` : '/documents';
|
||||||
|
}
|
||||||
|
|
||||||
function handleTextSearch() {
|
function handleTextSearch() {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||||
@@ -115,10 +145,12 @@ $effect(() => {
|
|||||||
|
|
||||||
<DocumentList
|
<DocumentList
|
||||||
items={data.items}
|
items={data.items}
|
||||||
total={data.total}
|
total={data.totalElements}
|
||||||
q={data.q}
|
q={data.q}
|
||||||
canWrite={data.canWrite}
|
canWrite={data.canWrite}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('documents page load — search params', () => {
|
|||||||
it('passes q, from, to to the search API', async () => {
|
it('passes q, from, to to the search API', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -49,7 +49,7 @@ describe('documents page load — search params', () => {
|
|||||||
it('passes senderId and receiverId to the search API', async () => {
|
it('passes senderId and receiverId to the search API', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -73,7 +73,7 @@ describe('documents page load — search params', () => {
|
|||||||
it('passes sort, dir, tagQ to the search API', async () => {
|
it('passes sort, dir, tagQ to the search API', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -103,7 +103,7 @@ describe('documents page load — search params', () => {
|
|||||||
};
|
};
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [item], total: 42 }
|
data: { items: [item], totalElements: 42, pageNumber: 0, pageSize: 50, totalPages: 1 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -115,13 +115,13 @@ describe('documents page load — search params', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.total).toBe(42);
|
expect(result.totalElements).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns filter values in the result for pre-filling the UI', async () => {
|
it('returns filter values in the result for pre-filling the UI', async () => {
|
||||||
const mockGet = vi.fn().mockResolvedValue({
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
data: { items: [], total: 0 }
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
});
|
});
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
|
|||||||
@@ -118,4 +118,20 @@ describe('documents page — URL building', () => {
|
|||||||
expect.objectContaining({ keepFocus: true, noScroll: true })
|
expect.objectContaining({ keepFocus: true, noScroll: true })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filter change does not carry the current page — goto URL drops page param', async () => {
|
||||||
|
const { goto } = await import('$app/navigation');
|
||||||
|
vi.mocked(goto).mockClear();
|
||||||
|
|
||||||
|
// User is mid-way through results at page 5; change the search text.
|
||||||
|
render(Page, { data: makeData({ q: 'old', pageNumber: 5 }) });
|
||||||
|
|
||||||
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||||
|
await input.fill('Brief');
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
const [url] = vi.mocked(goto).mock.calls[0];
|
||||||
|
expect(url).toContain('q=Brief');
|
||||||
|
expect(url).not.toContain('page=');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user