Compare commits
10 Commits
feat/issue
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18e675a5b2 | ||
|
|
a3fc838855 | ||
|
|
d5043053e0 | ||
|
|
c932dd19d9 | ||
|
|
c532ad21bf | ||
|
|
0e95bd9160 | ||
|
|
e312cce4e1 | ||
|
|
5587722800 | ||
|
|
0451b6630c | ||
|
|
f77fb79cd2 |
@@ -263,7 +263,7 @@ if (!result.response.ok) {
|
|||||||
return { person: result.data! }; // non-null assertion is safe after the ok check
|
return { person: result.data! }; // non-null assertion is safe after the ok check
|
||||||
```
|
```
|
||||||
|
|
||||||
For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie.
|
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
|
||||||
|
|
||||||
### Date handling
|
### Date handling
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.poi.ss.usermodel.*;
|
import org.apache.poi.ss.usermodel.*;
|
||||||
@@ -31,6 +33,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
@@ -53,9 +56,33 @@ public class MassImportService {
|
|||||||
|
|
||||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||||
|
|
||||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
public record SkippedFile(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||||
|
) {}
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
public record ImportStatus(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
|
||||||
|
@JsonIgnore String message,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
|
||||||
|
LocalDateTime startedAt
|
||||||
|
) {
|
||||||
|
// Note: @Schema on a record accessor method is not picked up by SpringDoc; the
|
||||||
|
// "skipped" count is a computed convenience field derived from skippedFiles.size().
|
||||||
|
@JsonProperty("skipped")
|
||||||
|
public int skipped() { return skippedFiles.size(); }
|
||||||
|
|
||||||
|
/** Defensive-copy constructor — callers cannot mutate the stored list after construction. */
|
||||||
|
public ImportStatus {
|
||||||
|
skippedFiles = List.copyOf(skippedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
|
||||||
|
|
||||||
|
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
public ImportStatus getStatus() {
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
@@ -117,22 +144,22 @@ public class MassImportService {
|
|||||||
if (currentStatus.state() == State.RUNNING) {
|
if (currentStatus.state() == State.RUNNING) {
|
||||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||||
}
|
}
|
||||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||||
try {
|
try {
|
||||||
File spreadsheet = findSpreadsheetFile();
|
File spreadsheet = findSpreadsheetFile();
|
||||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||||
processed, currentStatus.startedAt());
|
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||||
} catch (NoSpreadsheetException e) {
|
} catch (NoSpreadsheetException e) {
|
||||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Massenimport fehlgeschlagen", e);
|
log.error("Massenimport fehlgeschlagen", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,8 +281,10 @@ public class MassImportService {
|
|||||||
|
|
||||||
// --- Import logic (works on neutral List<String> rows) ---
|
// --- Import logic (works on neutral List<String> rows) ---
|
||||||
|
|
||||||
private int processRows(List<List<String>> rows) {
|
private ProcessResult processRows(List<List<String>> rows) {
|
||||||
int count = 0;
|
int processed = 0;
|
||||||
|
List<SkippedFile> skippedFiles = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 1; i < rows.size(); i++) { // skip header row
|
for (int i = 1; i < rows.size(); i++) { // skip header row
|
||||||
List<String> cells = rows.get(i);
|
List<String> cells = rows.get(i);
|
||||||
String index = getCell(cells, colIndex);
|
String index = getCell(cells, colIndex);
|
||||||
@@ -266,18 +295,58 @@ public class MassImportService {
|
|||||||
if (fileOnDisk.isEmpty()) {
|
if (fileOnDisk.isEmpty()) {
|
||||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||||
}
|
}
|
||||||
importSingleDocument(cells, fileOnDisk, filename, index);
|
|
||||||
count++;
|
if (fileOnDisk.isPresent()) {
|
||||||
|
try {
|
||||||
|
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||||
|
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||||
|
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||||
|
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||||
|
if (skipReason.isPresent()) {
|
||||||
|
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
||||||
|
} else {
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return new ProcessResult(processed, skippedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// package-private: Mockito spy in tests can override to inject IOException
|
||||||
|
InputStream openFileStream(File file) throws IOException {
|
||||||
|
return new FileInputStream(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||||
|
try (InputStream is = openFileStream(file)) {
|
||||||
|
byte[] header = is.readNBytes(4);
|
||||||
|
return header.length == 4
|
||||||
|
&& header[0] == 0x25 // %
|
||||||
|
&& header[1] == 0x50 // P
|
||||||
|
&& header[2] == 0x44 // D
|
||||||
|
&& header[3] == 0x46; // F
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports a single document row.
|
||||||
|
*
|
||||||
|
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
||||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||||
return;
|
return Optional.of("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
String archiveBox = getCell(cells, colBox);
|
String archiveBox = getCell(cells, colBox);
|
||||||
@@ -313,7 +382,7 @@ public class MassImportService {
|
|||||||
status = DocumentStatus.UPLOADED;
|
status = DocumentStatus.UPLOADED;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
||||||
return;
|
return Optional.of("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +424,7 @@ public class MassImportService {
|
|||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||||
}
|
}
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||||
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|||||||
@@ -31,6 +31,5 @@ public class InviteListItemDTO {
|
|||||||
private String status;
|
private String status;
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String shareableUrl;
|
private String shareableUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class MassImportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
@@ -154,9 +154,76 @@ class MassImportServiceTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
|
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
|
||||||
|
// Document already exists with status UPLOADED (not PLACEHOLDER).
|
||||||
|
// A physical PDF file is also present on disk (valid magic bytes).
|
||||||
|
// Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
|
||||||
|
// the guard fires before any file I/O, so no partial processing occurs.
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("present.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
Path physicalFile = tempDir.resolve("present.pdf");
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(physicalFile, pdfHeader);
|
||||||
|
|
||||||
|
Optional<String> result = service.importSingleDocument(
|
||||||
|
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
|
||||||
|
|
||||||
|
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
verify(documentService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
|
||||||
|
buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
|
||||||
|
doThrow(new RuntimeException("S3 unavailable"))
|
||||||
|
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
|
||||||
|
buildMinimalImportXlsx(tempDir, "existing.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("existing.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||||
@@ -208,7 +275,7 @@ class MassImportServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
||||||
Path tempFile = tempDir.resolve("fail.pdf");
|
Path tempFile = tempDir.resolve("fail.pdf");
|
||||||
Files.write(tempFile, "data".getBytes());
|
Files.write(tempFile, "data".getBytes());
|
||||||
|
|
||||||
@@ -216,10 +283,11 @@ class MassImportServiceTest {
|
|||||||
doThrow(new RuntimeException("S3 error"))
|
doThrow(new RuntimeException("S3 error"))
|
||||||
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
|
||||||
service.importSingleDocument(
|
Optional<String> result = service.importSingleDocument(
|
||||||
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
|
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — sender handling ───────────────────────────────
|
// ─── importSingleDocument — sender handling ───────────────────────────────
|
||||||
@@ -325,8 +393,8 @@ class MassImportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void processRows_returnsZero_whenOnlyHeaderRow() {
|
void processRows_returnsZero_whenOnlyHeaderRow() {
|
||||||
List<List<String>> rows = List.of(List.of("header", "col1"));
|
List<List<String>> rows = List.of(List.of("header", "col1"));
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
assertThat(result).isEqualTo(0);
|
assertThat(result.processed()).isEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -335,8 +403,8 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("") // blank index
|
minimalCells("") // blank index
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
assertThat(result).isEqualTo(0);
|
assertThat(result.processed()).isEqualTo(0);
|
||||||
verify(documentService, never()).findByOriginalFilename(any());
|
verify(documentService, never()).findByOriginalFilename(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,9 +417,9 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("doc001") // no dot → appends ".pdf"
|
minimalCells("doc001") // no dot → appends ".pdf"
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
assertThat(result.processed()).isEqualTo(1);
|
||||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
verify(documentService).findByOriginalFilename("doc001.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,9 +432,9 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("doc002.pdf") // has dot → used as-is
|
minimalCells("doc002.pdf") // has dot → used as-is
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
assertThat(result.processed()).isEqualTo(1);
|
||||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +593,67 @@ class MassImportServiceTest {
|
|||||||
assertThat(result).isEqualTo("hello");
|
assertThat(result).isEqualTo("hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PDF magic byte validation regression ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
|
||||||
|
setupOneValidOneFakeImport(tempDir);
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
|
||||||
|
setupOneValidOneFakeImport(tempDir);
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
|
||||||
|
setupOneValidOneFakeImport(tempDir);
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::filename)
|
||||||
|
.contains("fake.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
|
||||||
|
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
|
||||||
|
buildMinimalImportXlsx(tempDir, "tiny.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
|
||||||
|
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
|
||||||
|
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
MassImportService spyService = spy(service);
|
||||||
|
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
|
||||||
|
|
||||||
|
spyService.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(spyService.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly("FILE_READ_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||||
|
|
||||||
// Security regression — do not remove.
|
// Security regression — do not remove.
|
||||||
@@ -621,4 +750,28 @@ class MassImportServiceTest {
|
|||||||
}
|
}
|
||||||
return destination.toFile();
|
return destination.toFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||||
|
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||||
|
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("import.xlsx");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
sheet.createRow(0).createCell(0).setCellValue("Index");
|
||||||
|
for (int i = 0; i < filenames.length; i++) {
|
||||||
|
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class AdminControllerTest {
|
|||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
when(massImportService.getStatus()).thenReturn(status);
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/import-status"))
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
@@ -61,7 +61,7 @@ class AdminControllerTest {
|
|||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
when(massImportService.getStatus()).thenReturn(status);
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/import-status"))
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ _See also [Annotation](#annotation-documentannotation)._
|
|||||||
|
|
||||||
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
|
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
|
||||||
|
|
||||||
|
**SkippedFile** (`MassImportService.SkippedFile`) — a file that was presented for import but not processed, recorded with a `filename` and a `reason` code. Possible reasons: `INVALID_PDF_SIGNATURE` (magic-byte validation failed), `S3_UPLOAD_FAILED` (file upload to MinIO/S3 threw an exception), `FILE_READ_ERROR` (the file could not be opened for reading), or `ALREADY_EXISTS` (a document with the same filename already exists in the archive with a status other than `PLACEHOLDER`).
|
||||||
|
|
||||||
|
**skipped count** — the total number of `SkippedFile` entries accumulated during a single import run (`ImportStatus.skipped()`). Shown in the amber warning section of the Import Status Card in the admin UI; a value of zero suppresses the section entirely.
|
||||||
|
|
||||||
**Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists.
|
**Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists.
|
||||||
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,11 @@
|
|||||||
"admin_system_import_status_running": "Import läuft…",
|
"admin_system_import_status_running": "Import läuft…",
|
||||||
"admin_system_import_status_done": "Import abgeschlossen",
|
"admin_system_import_status_done": "Import abgeschlossen",
|
||||||
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||||
|
"admin_system_import_skipped_label": "übersprungen",
|
||||||
|
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
|
||||||
|
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
|
||||||
|
"import_reason_s3_upload_failed": "Upload-Fehler (S3)",
|
||||||
|
"import_reason_already_exists": "Bereits importiert",
|
||||||
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||||
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||||
|
|||||||
@@ -352,6 +352,11 @@
|
|||||||
"admin_system_import_status_running": "Import running…",
|
"admin_system_import_status_running": "Import running…",
|
||||||
"admin_system_import_status_done": "Import complete",
|
"admin_system_import_status_done": "Import complete",
|
||||||
"admin_system_import_status_done_label": "Documents processed",
|
"admin_system_import_status_done_label": "Documents processed",
|
||||||
|
"admin_system_import_skipped_label": "skipped",
|
||||||
|
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
|
||||||
|
"import_reason_file_read_error": "File read error",
|
||||||
|
"import_reason_s3_upload_failed": "Upload error (S3)",
|
||||||
|
"import_reason_already_exists": "Already imported",
|
||||||
"admin_system_import_status_failed": "Import failed",
|
"admin_system_import_status_failed": "Import failed",
|
||||||
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||||
|
|||||||
@@ -352,6 +352,11 @@
|
|||||||
"admin_system_import_status_running": "Importación en curso…",
|
"admin_system_import_status_running": "Importación en curso…",
|
||||||
"admin_system_import_status_done": "Importación completada",
|
"admin_system_import_status_done": "Importación completada",
|
||||||
"admin_system_import_status_done_label": "Documentos procesados",
|
"admin_system_import_status_done_label": "Documentos procesados",
|
||||||
|
"admin_system_import_skipped_label": "omitidos",
|
||||||
|
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
|
||||||
|
"import_reason_file_read_error": "Error al leer el archivo",
|
||||||
|
"import_reason_s3_upload_failed": "Error de carga (S3)",
|
||||||
|
"import_reason_already_exists": "Ya importado",
|
||||||
"admin_system_import_status_failed": "Importación fallida",
|
"admin_system_import_status_failed": "Importación fallida",
|
||||||
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
|
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|||||||
@@ -180,22 +180,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/users/{id}/force-logout": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["forceLogout"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/users/me/password": {
|
"/api/users/me/password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -596,38 +580,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/auth/logout": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["logout"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/auth/login": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["login"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/auth/forgot-password": {
|
"/api/auth/forgot-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1897,7 +1849,7 @@ export interface components {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
shareableUrl: string;
|
shareableUrl?: string;
|
||||||
};
|
};
|
||||||
GroupDTO: {
|
GroupDTO: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -2059,17 +2011,13 @@ export interface components {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
notifyOnMention?: boolean;
|
notifyOnMention?: boolean;
|
||||||
};
|
};
|
||||||
LoginRequest: {
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
ForgotPasswordRequest: {
|
ForgotPasswordRequest: {
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
ImportStatus: {
|
ImportStatus: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||||
statusCode?: string;
|
message?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
processed?: number;
|
processed?: number;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -2307,14 +2255,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;
|
||||||
@@ -2462,7 +2410,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ActivityFeedItemDTO: {
|
ActivityFeedItemDTO: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
|
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
|
||||||
actor?: components["schemas"]["ActivityActorDTO"];
|
actor?: components["schemas"]["ActivityActorDTO"];
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -3006,30 +2954,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
forceLogout: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": {
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
changePassword: {
|
changePassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3623,7 +3547,6 @@ export interface operations {
|
|||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
documentId: string;
|
|
||||||
blockId: string;
|
blockId: string;
|
||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -3674,7 +3597,6 @@ export interface operations {
|
|||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
blockId: string;
|
|
||||||
commentId: string;
|
commentId: string;
|
||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -3869,48 +3791,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
logout: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
login: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["LoginRequest"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["AppUser"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
forgotPassword: {
|
forgotPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -5105,7 +4985,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["DocumentDensityResult"];
|
"*/*": components["schemas"]["DocumentDensityResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -5181,7 +5061,7 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
||||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
|
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -19,22 +19,14 @@ describe('admin/groups layout load', () => {
|
|||||||
{ id: 'g1', name: 'Admins', permissions: ['ADMIN'] },
|
{ id: 'g1', name: 'Admins', permissions: ['ADMIN'] },
|
||||||
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
|
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
|
||||||
]);
|
]);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/groups'),
|
|
||||||
url: new URL('http://localhost/admin/groups')
|
|
||||||
});
|
|
||||||
expect(result.groups).toHaveLength(2);
|
expect(result.groups).toHaveLength(2);
|
||||||
expect(result.groups[0].name).toBe('Admins');
|
expect(result.groups[0].name).toBe('Admins');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty array when the API returns nothing', async () => {
|
it('returns an empty array when the API returns nothing', async () => {
|
||||||
mockApi([]);
|
mockApi([]);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/groups'),
|
|
||||||
url: new URL('http://localhost/admin/groups')
|
|
||||||
});
|
|
||||||
expect(result.groups).toEqual([]);
|
expect(result.groups).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,11 +35,7 @@ describe('admin/groups layout load', () => {
|
|||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
await load({
|
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/groups'),
|
|
||||||
url: new URL('http://localhost/admin/groups')
|
|
||||||
});
|
|
||||||
expect(mockGet).toHaveBeenCalledWith('/api/groups');
|
expect(mockGet).toHaveBeenCalledWith('/api/groups');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +1,50 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { env } from '$env/dynamic/private';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { parseBackendError } from '$lib/shared/errors';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
export type InviteListItem = components['schemas']['InviteListItemDTO'];
|
export interface InviteListItem {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
displayCode: string;
|
||||||
|
label?: string;
|
||||||
|
useCount: number;
|
||||||
|
maxUses?: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
revoked: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
shareableUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type UserGroup = components['schemas']['UserGroup'];
|
export type UserGroup = components['schemas']['UserGroup'];
|
||||||
|
|
||||||
const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const;
|
|
||||||
type InviteStatus = (typeof VALID_STATUSES)[number];
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||||
const rawStatus = url.searchParams.get('status');
|
const status = url.searchParams.get('status') ?? 'active';
|
||||||
const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus)
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
? (rawStatus as InviteStatus)
|
|
||||||
: 'ACTIVE';
|
|
||||||
const api = createApiClient(fetch);
|
|
||||||
|
|
||||||
const [invitesResult, groupsResult] = await Promise.all([
|
const [invitesRes, groupsRes] = await Promise.all([
|
||||||
api.GET('/api/invites', { params: { query: { status } } }),
|
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
|
||||||
api.GET('/api/groups')
|
fetch(`${apiUrl}/api/groups`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let invites: InviteListItem[] = [];
|
let invites: InviteListItem[] = [];
|
||||||
let loadError: string | null = null;
|
let loadError: string | null = null;
|
||||||
if (!invitesResult.response.ok) {
|
if (!invitesRes.ok) {
|
||||||
const code = (invitesResult.error as unknown as { code?: string })?.code;
|
const backendError = await parseBackendError(invitesRes);
|
||||||
loadError = code ?? 'INTERNAL_ERROR';
|
loadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
} else {
|
} else {
|
||||||
invites = (invitesResult.data ?? []) as InviteListItem[];
|
invites = await invitesRes.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
let groups: UserGroup[] = [];
|
let groups: UserGroup[] = [];
|
||||||
let groupsLoadError: string | null = null;
|
let groupsLoadError: string | null = null;
|
||||||
if (!groupsResult.response.ok) {
|
if (!groupsRes.ok) {
|
||||||
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
const backendError = await parseBackendError(groupsRes);
|
||||||
groupsLoadError = code ?? 'INTERNAL_ERROR';
|
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
} else {
|
} else {
|
||||||
const raw = groupsResult.data ?? [];
|
const raw: UserGroup[] = await groupsRes.json();
|
||||||
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,30 +63,42 @@ export const actions = {
|
|||||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
const groupIds = formData.getAll('groupIds') as string[];
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const result = await api.POST('/api/invites', {
|
const res = await fetch(`${apiUrl}/api/invites`, {
|
||||||
body: { label, maxUses, prefillFirstName, prefillLastName, prefillEmail, expiresAt, groupIds }
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
label,
|
||||||
|
maxUses,
|
||||||
|
prefillFirstName,
|
||||||
|
prefillLastName,
|
||||||
|
prefillEmail,
|
||||||
|
expiresAt,
|
||||||
|
groupIds
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!res.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const backendError = await parseBackendError(res);
|
||||||
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
|
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created: result.data! as InviteListItem };
|
const created: InviteListItem = await res.json();
|
||||||
|
return { created };
|
||||||
},
|
},
|
||||||
|
|
||||||
revoke: async ({ request, fetch }) => {
|
revoke: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const id = formData.get('id') as string | null;
|
const id = formData.get('id') as string;
|
||||||
if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') });
|
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
|
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!res.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const backendError = await parseBackendError(res);
|
||||||
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
|
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { revoked: id };
|
return { revoked: id };
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('$env/dynamic/private', () => ({
|
|
||||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { load, actions } from './+page.server';
|
|
||||||
import type { UserGroup } from './+page.server';
|
|
||||||
|
|
||||||
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
|
||||||
// the void and the Record<string, any> from the generic constraint.
|
|
||||||
type LoadData = {
|
|
||||||
invites: unknown[];
|
|
||||||
status: string;
|
|
||||||
loadError: string | null;
|
|
||||||
groups: UserGroup[];
|
|
||||||
groupsLoadError: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type AnyFetch = (...args: any[]) => any;
|
|
||||||
|
|
||||||
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
json: async () => body,
|
|
||||||
text: async () => JSON.stringify(body),
|
|
||||||
headers: new Headers({ 'content-type': 'application/json' })
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('admin/invites load()', () => {
|
|
||||||
const mockFetch = vi.fn<AnyFetch>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
|
||||||
|
|
||||||
function event(status = 'active') {
|
|
||||||
const url = new URL(`http://localhost/admin/invites?status=${status}`);
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
request: new Request(url),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns groups array alongside invites when both succeed', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
|
||||||
mockResponse(true, [
|
|
||||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
|
||||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groups).toHaveLength(2);
|
|
||||||
expect(result.groupsLoadError).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns groups sorted alphabetically by name', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
|
||||||
mockResponse(true, [
|
|
||||||
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
|
||||||
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
|
||||||
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []))
|
|
||||||
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groups).toEqual([]);
|
|
||||||
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []))
|
|
||||||
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []))
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []));
|
|
||||||
|
|
||||||
await load(event());
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
// createApiClient calls fetch(Request, {}), not fetch(string, init)
|
|
||||||
const urls = mockFetch.mock.calls.map((call) => (call[0] as Request).url);
|
|
||||||
expect(urls).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.stringContaining('/api/invites'),
|
|
||||||
expect.stringContaining('/api/groups')
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('admin/invites create action', () => {
|
|
||||||
const mockFetch = vi.fn<AnyFetch>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
|
||||||
|
|
||||||
const successBody = {
|
|
||||||
id: 'inv-1',
|
|
||||||
code: 'ABCDE12345',
|
|
||||||
displayCode: 'ABCDE-12345',
|
|
||||||
status: 'active',
|
|
||||||
revoked: false,
|
|
||||||
useCount: 0,
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
|
||||||
};
|
|
||||||
|
|
||||||
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('groupIds', 'g-1');
|
|
||||||
fd.append('groupIds', 'g-2');
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
|
||||||
|
|
||||||
await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// createApiClient calls fetch(Request, {}), not fetch(string, init)
|
|
||||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
|
||||||
expect(req).toBeInstanceOf(Request);
|
|
||||||
expect(req.url).toContain('/api/invites');
|
|
||||||
const sent = await req.json();
|
|
||||||
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
|
||||||
|
|
||||||
await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
|
||||||
expect(req).toBeInstanceOf(Request);
|
|
||||||
const sent = await req.json();
|
|
||||||
expect(sent.groupIds).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns created invite on success', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
|
||||||
|
|
||||||
const result = await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ created: expect.objectContaining({ id: 'inv-1' }) });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail with backend error code when create returns non-OK', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
|
||||||
|
|
||||||
const result = await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 403, data: { createError: 'FORBIDDEN' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to INTERNAL_ERROR when create error body has no code', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(false, null, 500));
|
|
||||||
|
|
||||||
const result = await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 500, data: { createError: 'INTERNAL_ERROR' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes expiresAt in POST body when provided', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('expiresAt', '2026-12-31');
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
|
||||||
|
|
||||||
await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
|
||||||
const sent = await req.json();
|
|
||||||
expect(sent.expiresAt).toBe('2026-12-31');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('admin/invites revoke action', () => {
|
|
||||||
const mockFetch = vi.fn<AnyFetch>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
|
||||||
|
|
||||||
it('calls DELETE /api/invites/{id} via createApiClient', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('id', 'inv-abc');
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
|
|
||||||
|
|
||||||
await actions.revoke({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
|
||||||
expect(req).toBeInstanceOf(Request);
|
|
||||||
expect(req.url).toContain('/api/invites/inv-abc');
|
|
||||||
expect(req.method).toBe('DELETE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns revoked id on success', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('id', 'inv-abc');
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
|
|
||||||
|
|
||||||
const result = await actions.revoke({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(result).toEqual({ revoked: 'inv-abc' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail with backend error code when revoke returns non-OK', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('id', 'inv-abc');
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'NOT_FOUND' }, 404));
|
|
||||||
|
|
||||||
const result = await actions.revoke({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 404, data: { revokeError: 'NOT_FOUND' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail(400) when revoke id is missing', async () => {
|
|
||||||
const result = await actions.revoke({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: new FormData() }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
expect(result).toMatchObject({ status: 400 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
155
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
155
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { load, actions } from './+page.server';
|
||||||
|
import type { UserGroup } from './+page.server';
|
||||||
|
|
||||||
|
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
||||||
|
// the void and the Record<string, any> from the generic constraint.
|
||||||
|
type LoadData = {
|
||||||
|
invites: unknown[];
|
||||||
|
status: string;
|
||||||
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyFetch = (...args: any[]) => any;
|
||||||
|
|
||||||
|
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' })
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin/invites load()', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
function event(status = 'active') {
|
||||||
|
return {
|
||||||
|
url: new URL(`http://localhost/admin/invites?status=${status}`),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns groups array alongside invites when both succeed', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
|
mockResponse(true, [
|
||||||
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||||
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(2);
|
||||||
|
expect(result.groupsLoadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups sorted alphabetically by name', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
|
mockResponse(true, [
|
||||||
|
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||||
|
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||||
|
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []));
|
||||||
|
|
||||||
|
await load(event());
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/invites create action', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
const successBody = {
|
||||||
|
id: 'inv-1',
|
||||||
|
code: 'ABCDE12345',
|
||||||
|
displayCode: 'ABCDE-12345',
|
||||||
|
status: 'active',
|
||||||
|
revoked: false,
|
||||||
|
useCount: 0,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('groupIds', 'g-1');
|
||||||
|
fd.append('groupIds', 'g-2');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,46 +26,26 @@ beforeEach(() => vi.clearAllMocks());
|
|||||||
describe('admin layout load — permission check', () => {
|
describe('admin layout load — permission check', () => {
|
||||||
it('throws 403 when user has no admin permission', async () => {
|
it('throws 403 when user has no admin permission', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: noPermUser } })
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin'),
|
|
||||||
url: new URL('http://localhost/admin'),
|
|
||||||
locals: { user: noPermUser }
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 when user is undefined', async () => {
|
it('throws 403 when user is undefined', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } })
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin'),
|
|
||||||
url: new URL('http://localhost/admin'),
|
|
||||||
locals: { user: undefined }
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 when user has no groups', async () => {
|
it('throws 403 when user has no groups', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } })
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin'),
|
|
||||||
url: new URL('http://localhost/admin'),
|
|
||||||
locals: { user: { groups: [] } }
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows access for a user with ADMIN_TAG only', async () => {
|
it('allows access for a user with ADMIN_TAG only', async () => {
|
||||||
mockApi([], [], []);
|
mockApi([], [], []);
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: tagAdminUser } })
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin'),
|
|
||||||
url: new URL('http://localhost/admin'),
|
|
||||||
locals: { user: tagAdminUser }
|
|
||||||
})
|
|
||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,8 +63,6 @@ describe('admin layout load — permission check', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
fetch: mockFetch as unknown as typeof fetch,
|
fetch: mockFetch as unknown as typeof fetch,
|
||||||
request: new Request('http://localhost/admin'),
|
|
||||||
url: new URL('http://localhost/admin'),
|
|
||||||
locals: { user: adminUser }
|
locals: { user: adminUser }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ describe('admin/ocr/[personId] — load', () => {
|
|||||||
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
|
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await load({
|
const result = (await load({ params: { personId }, fetch } as never))!;
|
||||||
params: { personId },
|
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/admin/ocr/123'),
|
|
||||||
url: new URL('http://localhost/admin/ocr/123')
|
|
||||||
} as never))!;
|
|
||||||
|
|
||||||
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
|
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
|
||||||
});
|
});
|
||||||
@@ -32,12 +27,7 @@ describe('admin/ocr/[personId] — load', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ params: { personId: 'unknown-id' }, fetch } as never)
|
||||||
params: { personId: 'unknown-id' },
|
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/admin/ocr/unknown-id'),
|
|
||||||
url: new URL('http://localhost/admin/ocr/unknown-id')
|
|
||||||
} as never)
|
|
||||||
).rejects.toMatchObject({ status: 404 });
|
).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ describe('admin/ocr/global — load', () => {
|
|||||||
data: { runs: [{ id: 'run1' }], personNames: {} }
|
data: { runs: [{ id: 'run1' }], personNames: {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await load({
|
const result = (await load({ fetch } as never))!;
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/admin/ocr/global'),
|
|
||||||
url: new URL('http://localhost/admin/ocr/global')
|
|
||||||
} as never))!;
|
|
||||||
|
|
||||||
expect(result.history.runs).toHaveLength(1);
|
expect(result.history.runs).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -26,12 +22,6 @@ describe('admin/ocr/global — load', () => {
|
|||||||
it('throws error when API call fails', async () => {
|
it('throws error when API call fails', async () => {
|
||||||
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
|
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
|
||||||
|
|
||||||
await expect(
|
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 });
|
||||||
load({
|
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/admin/ocr/global'),
|
|
||||||
url: new URL('http://localhost/admin/ocr/global')
|
|
||||||
} as never)
|
|
||||||
).rejects.toMatchObject({ status: 500 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ describe('admin/ocr — load', () => {
|
|||||||
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
|
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await load({
|
const result = (await load({ fetch } as never))!;
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/admin/ocr'),
|
|
||||||
url: new URL('http://localhost/admin/ocr')
|
|
||||||
} as never))!;
|
|
||||||
|
|
||||||
expect(result.trainingInfo.availableBlocks).toBe(10);
|
expect(result.trainingInfo.availableBlocks).toBe(10);
|
||||||
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
|
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
|
||||||
@@ -27,12 +23,6 @@ describe('admin/ocr — load', () => {
|
|||||||
it('throws 503 when OCR API call fails', async () => {
|
it('throws 503 when OCR API call fails', async () => {
|
||||||
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
|
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
|
||||||
|
|
||||||
await expect(
|
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 });
|
||||||
load({
|
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/admin/ocr'),
|
|
||||||
url: new URL('http://localhost/admin/ocr')
|
|
||||||
} as never)
|
|
||||||
).rejects.toMatchObject({ status: 503 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ const failureMessage = $derived(
|
|||||||
? m.admin_system_import_failed_no_spreadsheet()
|
? m.admin_system_import_failed_no_spreadsheet()
|
||||||
: m.admin_system_import_failed_internal()
|
: m.admin_system_import_failed_internal()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function reasonLabel(code: string): string {
|
||||||
|
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
|
||||||
|
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
|
||||||
|
if (code === 'S3_UPLOAD_FAILED') return m.import_reason_s3_upload_failed();
|
||||||
|
if (code === 'ALREADY_EXISTS') return m.import_reason_already_exists();
|
||||||
|
return code;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
@@ -48,6 +56,41 @@ const failureMessage = $derived(
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div aria-live="polite">
|
||||||
|
{#if importStatus.skipped > 0}
|
||||||
|
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-amber-900">
|
||||||
|
<summary class="flex min-h-[44px] cursor-pointer list-none items-center gap-2 py-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="details-chevron h-4 w-4 shrink-0 motion-safe:transition-transform"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 4l4 4-4 4" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span data-testid="skipped-count" class="block text-base font-bold"
|
||||||
|
>{importStatus.skipped}</span
|
||||||
|
>
|
||||||
|
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
|
||||||
|
{m.admin_system_import_skipped_label()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<ul class="mt-3 max-h-64 space-y-1 overflow-y-auto">
|
||||||
|
{#each importStatus.skippedFiles as skipped (skipped.filename)}
|
||||||
|
<li class="font-mono text-sm text-ink-2">
|
||||||
|
{skipped.filename} — {reasonLabel(skipped.reason)}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
data-import-trigger
|
data-import-trigger
|
||||||
onclick={ontrigger}
|
onclick={ontrigger}
|
||||||
@@ -79,3 +122,9 @@ const failureMessage = $derived(
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
details[open] .details-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
|
|||||||
state: 'IDLE',
|
state: 'IDLE',
|
||||||
statusCode: 'IMPORT_IDLE',
|
statusCode: 'IMPORT_IDLE',
|
||||||
processed: 0,
|
processed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
skippedFiles: [],
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
@@ -128,4 +130,106 @@ describe('ImportStatusCard', () => {
|
|||||||
await getByRole('button').click();
|
await getByRole('button').click();
|
||||||
expect(ontrigger).toHaveBeenCalledOnce();
|
expect(ontrigger).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows skipped count when DONE and skipped > 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'DONE',
|
||||||
|
statusCode: 'IMPORT_DONE',
|
||||||
|
processed: 10,
|
||||||
|
skipped: 3,
|
||||||
|
skippedFiles: [
|
||||||
|
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
|
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
|
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows skipped filenames in collapsible list when DONE and skipped > 0', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'DONE',
|
||||||
|
statusCode: 'IMPORT_DONE',
|
||||||
|
processed: 5,
|
||||||
|
skipped: 1,
|
||||||
|
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('fake.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skipped section when DONE and skipped is 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skipped section when RUNNING even with skipped > 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'RUNNING',
|
||||||
|
statusCode: 'IMPORT_RUNNING',
|
||||||
|
processed: 5,
|
||||||
|
skipped: 2,
|
||||||
|
skippedFiles: [
|
||||||
|
{ filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
|
{ filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skipped section when FAILED even with skipped > 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'FAILED',
|
||||||
|
statusCode: 'IMPORT_FAILED_INTERNAL',
|
||||||
|
skipped: 1,
|
||||||
|
skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows raw reason code for unknown skip reasons', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'DONE',
|
||||||
|
statusCode: 'IMPORT_DONE',
|
||||||
|
processed: 1,
|
||||||
|
skipped: 1,
|
||||||
|
skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
export type SkippedFile = {
|
||||||
|
filename: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImportStatus = {
|
export type ImportStatus = {
|
||||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||||
statusCode: string;
|
statusCode: string;
|
||||||
processed: number;
|
processed: number;
|
||||||
|
skipped: number;
|
||||||
|
skippedFiles: SkippedFile[];
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ describe('tags/[id] — load function', () => {
|
|||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
||||||
request: new Request('http://localhost/admin/tags/t1'),
|
|
||||||
url
|
url
|
||||||
} as never);
|
} as never);
|
||||||
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
|
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
|
||||||
@@ -32,7 +31,6 @@ describe('tags/[id] — load function', () => {
|
|||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
||||||
request: new Request('http://localhost/admin/tags/t1'),
|
|
||||||
url
|
url
|
||||||
} as never);
|
} as never);
|
||||||
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);
|
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);
|
||||||
|
|||||||
@@ -44,22 +44,14 @@ const sampleTree = [
|
|||||||
describe('admin/tags layout load', () => {
|
describe('admin/tags layout load', () => {
|
||||||
it('returns the tree list', async () => {
|
it('returns the tree list', async () => {
|
||||||
mockTreeApi(sampleTree);
|
mockTreeApi(sampleTree);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/tags'),
|
|
||||||
url: new URL('http://localhost/admin/tags')
|
|
||||||
});
|
|
||||||
expect(result.tree).toHaveLength(2);
|
expect(result.tree).toHaveLength(2);
|
||||||
expect(result.tree[0].name).toBe('Familie');
|
expect(result.tree[0].name).toBe('Familie');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty tree when the API returns nothing', async () => {
|
it('returns an empty tree when the API returns nothing', async () => {
|
||||||
mockTreeApi([]);
|
mockTreeApi([]);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/tags'),
|
|
||||||
url: new URL('http://localhost/admin/tags')
|
|
||||||
});
|
|
||||||
expect(result.tree).toEqual([]);
|
expect(result.tree).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,21 +60,13 @@ describe('admin/tags layout load', () => {
|
|||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
await load({
|
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/tags'),
|
|
||||||
url: new URL('http://localhost/admin/tags')
|
|
||||||
});
|
|
||||||
expect(mockGet).toHaveBeenCalledWith('/api/tags/tree');
|
expect(mockGet).toHaveBeenCalledWith('/api/tags/tree');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flattens the tree into a flat tags array', async () => {
|
it('flattens the tree into a flat tags array', async () => {
|
||||||
mockTreeApi(sampleTree);
|
mockTreeApi(sampleTree);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/tags'),
|
|
||||||
url: new URL('http://localhost/admin/tags')
|
|
||||||
});
|
|
||||||
// Both parent and child should be in the flat array
|
// Both parent and child should be in the flat array
|
||||||
expect(result.tags).toHaveLength(3);
|
expect(result.tags).toHaveLength(3);
|
||||||
expect(result.tags.map((t) => t.name)).toContain('Eltern');
|
expect(result.tags.map((t) => t.name)).toContain('Eltern');
|
||||||
@@ -90,22 +74,14 @@ describe('admin/tags layout load', () => {
|
|||||||
|
|
||||||
it('preserves parentId on child tags in the flat array', async () => {
|
it('preserves parentId on child tags in the flat array', async () => {
|
||||||
mockTreeApi(sampleTree);
|
mockTreeApi(sampleTree);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/tags'),
|
|
||||||
url: new URL('http://localhost/admin/tags')
|
|
||||||
});
|
|
||||||
const child = result.tags.find((t) => t.name === 'Eltern');
|
const child = result.tags.find((t) => t.name === 'Eltern');
|
||||||
expect(child?.parentId).toBe('parent1');
|
expect(child?.parentId).toBe('parent1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets parentId to undefined on root tags in the flat array', async () => {
|
it('sets parentId to undefined on root tags in the flat array', async () => {
|
||||||
mockTreeApi(sampleTree);
|
mockTreeApi(sampleTree);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/tags'),
|
|
||||||
url: new URL('http://localhost/admin/tags')
|
|
||||||
});
|
|
||||||
const root = result.tags.find((t) => t.name === 'Familie');
|
const root = result.tags.find((t) => t.name === 'Familie');
|
||||||
expect(root?.parentId).toBeUndefined();
|
expect(root?.parentId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
|
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -46,17 +45,21 @@ export const actions: Actions = {
|
|||||||
groupIds: data.getAll('groupIds') as string[]
|
groupIds: data.getAll('groupIds') as string[]
|
||||||
};
|
};
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const res = await fetch(`/api/users/${params.id}`, {
|
||||||
const result = await api.PUT('/api/users/{id}', {
|
method: 'PUT',
|
||||||
params: { path: { id: params.id } },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// Body may contain null for fields the user cleared; the backend treats
|
body: JSON.stringify(body)
|
||||||
// null as "clear this field". Cast to satisfy the optional-only spec type.
|
|
||||||
body: body as components['schemas']['AdminUpdateUserRequest']
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!res.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
let code: string | undefined;
|
||||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
code = json?.code;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return fail(res.status, { error: getErrorMessage(code) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('$env/dynamic/private', () => ({
|
|
||||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
|
||||||
|
|
||||||
import { load, actions } from './+page.server';
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
|
||||||
|
|
||||||
function mockApi(methods: Partial<Record<'GET' | 'PUT' | 'DELETE', ReturnType<typeof vi.fn>>>) {
|
|
||||||
vi.mocked(createApiClient).mockReturnValue(methods as ReturnType<typeof createApiClient>);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── load() ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('admin/users/[id] load()', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
function makeEvent(permissions: string[] = ['ADMIN']) {
|
|
||||||
return {
|
|
||||||
params: { id: 'user-123' },
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
locals: { user: { groups: [{ permissions }] } },
|
|
||||||
request: new Request('http://localhost/admin/users/user-123'),
|
|
||||||
url: new URL('http://localhost/admin/users/user-123')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('throws 403 when the user lacks the ADMIN permission', async () => {
|
|
||||||
let thrown: unknown;
|
|
||||||
try {
|
|
||||||
await load(makeEvent(['READ_ALL']));
|
|
||||||
} catch (e) {
|
|
||||||
thrown = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect((thrown as { status: number }).status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 404 when the backend returns non-ok for the user lookup', async () => {
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({ response: { ok: false, status: 404 }, data: undefined })
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] });
|
|
||||||
mockApi({ GET: mockGet });
|
|
||||||
|
|
||||||
let thrown: unknown;
|
|
||||||
try {
|
|
||||||
await load(makeEvent());
|
|
||||||
} catch (e) {
|
|
||||||
thrown = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect((thrown as { status: number }).status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns editUser and groups on success', async () => {
|
|
||||||
const editUser = { id: 'user-123', email: 'max@example.com', firstName: 'Max' };
|
|
||||||
const groups = [
|
|
||||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
|
||||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
|
||||||
];
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: editUser })
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: groups });
|
|
||||||
mockApi({ GET: mockGet });
|
|
||||||
|
|
||||||
const result = await load(makeEvent());
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
editUser: expect.objectContaining({ id: 'user-123' }),
|
|
||||||
groups: expect.arrayContaining([expect.objectContaining({ id: 'g-1' })])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── update action ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('admin/users/[id] update action', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
function makeUpdateRequest(fields: Record<string, string | string[]> = {}) {
|
|
||||||
const fd = new FormData();
|
|
||||||
const defaults: Record<string, string> = {
|
|
||||||
firstName: 'Max',
|
|
||||||
lastName: 'Mustermann',
|
|
||||||
email: 'max@example.com'
|
|
||||||
};
|
|
||||||
for (const [k, v] of Object.entries({ ...defaults, ...fields })) {
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
v.forEach((item) => fd.append(k, item));
|
|
||||||
} else {
|
|
||||||
fd.append(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Request('http://localhost', { method: 'POST', body: fd });
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEvent(request: Request) {
|
|
||||||
return {
|
|
||||||
params: { id: 'user-123' },
|
|
||||||
request,
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('calls PUT /api/users/{id} and returns success: true on 200', async () => {
|
|
||||||
const mockPut = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
|
||||||
mockApi({ PUT: mockPut });
|
|
||||||
|
|
||||||
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
|
||||||
|
|
||||||
expect(mockPut).toHaveBeenCalledWith(
|
|
||||||
'/api/users/{id}',
|
|
||||||
expect.objectContaining({ params: { path: { id: 'user-123' } } })
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail with backend error code when PUT returns non-OK', async () => {
|
|
||||||
const mockPut = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
|
|
||||||
mockApi({ PUT: mockPut });
|
|
||||||
|
|
||||||
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 403 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail with generic message when error body has no code field', async () => {
|
|
||||||
const mockPut = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ response: { ok: false, status: 500 }, error: null });
|
|
||||||
mockApi({ PUT: mockPut });
|
|
||||||
|
|
||||||
const result = await actions.update(makeEvent(makeUpdateRequest()));
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail without calling backend when passwords do not match', async () => {
|
|
||||||
const mockPut = vi.fn();
|
|
||||||
mockApi({ PUT: mockPut });
|
|
||||||
|
|
||||||
const result = await actions.update(
|
|
||||||
makeEvent(makeUpdateRequest({ newPassword: 'abc', confirmPassword: 'xyz' }))
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockPut).not.toHaveBeenCalled();
|
|
||||||
expect(result).toMatchObject({ status: 400 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── delete action ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('admin/users/[id] delete action', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
function makeEvent() {
|
|
||||||
return {
|
|
||||||
params: { id: 'user-123' },
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/users/user-123')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('redirects to /admin/users on successful delete', async () => {
|
|
||||||
const mockDelete = vi.fn().mockResolvedValue({ response: { ok: true } });
|
|
||||||
mockApi({ DELETE: mockDelete });
|
|
||||||
|
|
||||||
let redirectLocation: string | null = null;
|
|
||||||
try {
|
|
||||||
await actions.delete(makeEvent());
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const r = e as { location?: string };
|
|
||||||
redirectLocation = r.location ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(redirectLocation).toBe('/admin/users');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail when delete returns non-OK', async () => {
|
|
||||||
const mockDelete = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ response: { ok: false, status: 403 }, error: { code: 'FORBIDDEN' } });
|
|
||||||
mockApi({ DELETE: mockDelete });
|
|
||||||
|
|
||||||
const result = await actions.delete(makeEvent());
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 403 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -19,24 +19,14 @@ describe('admin/users layout load', () => {
|
|||||||
{ id: 'u1', email: 'alice@example.com' },
|
{ id: 'u1', email: 'alice@example.com' },
|
||||||
{ id: 'u2', email: 'bob@example.com' }
|
{ id: 'u2', email: 'bob@example.com' }
|
||||||
]);
|
]);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/users'),
|
|
||||||
url: new URL('http://localhost/admin/users')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
expect(result.users).toHaveLength(2);
|
expect(result.users).toHaveLength(2);
|
||||||
expect(result.users[0].email).toBe('alice@example.com');
|
expect(result.users[0].email).toBe('alice@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty array when the API returns nothing', async () => {
|
it('returns an empty array when the API returns nothing', async () => {
|
||||||
mockApi([]);
|
mockApi([]);
|
||||||
const result = await load({
|
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/users'),
|
|
||||||
url: new URL('http://localhost/admin/users')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
expect(result.users).toEqual([]);
|
expect(result.users).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,12 +35,7 @@ describe('admin/users layout load', () => {
|
|||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
await load({
|
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/admin/users'),
|
|
||||||
url: new URL('http://localhost/admin/users')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
expect(mockGet).toHaveBeenCalledWith('/api/users');
|
expect(mockGet).toHaveBeenCalledWith('/api/users');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,11 +29,7 @@ beforeEach(() => {
|
|||||||
describe('aktivitaeten/load — core', () => {
|
describe('aktivitaeten/load — core', () => {
|
||||||
it('requests only unread notifications for Für-dich', async () => {
|
it('requests only unread notifications for Für-dich', async () => {
|
||||||
mockSuccess();
|
mockSuccess();
|
||||||
await load({
|
await load({ fetch, url: buildUrl() } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl()
|
|
||||||
} as never);
|
|
||||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
|
||||||
params: { query: { read: false, page: 0, size: 20 } }
|
params: { query: { read: false, page: 0, size: 20 } }
|
||||||
});
|
});
|
||||||
@@ -49,11 +45,7 @@ describe('aktivitaeten/load — core', () => {
|
|||||||
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
|
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({ fetch, url: buildUrl() } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl()
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(result.activityFeed).toEqual(feed);
|
expect(result.activityFeed).toEqual(feed);
|
||||||
expect(result.unreadNotifications).toEqual(unread);
|
expect(result.unreadNotifications).toEqual(unread);
|
||||||
@@ -69,11 +61,7 @@ describe('aktivitaeten/load — core', () => {
|
|||||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({ fetch, url: buildUrl() } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl()
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(result.loadError).toBe('activity');
|
expect(result.loadError).toBe('activity');
|
||||||
expect(result.activityFeed).toEqual([]);
|
expect(result.activityFeed).toEqual([]);
|
||||||
@@ -81,19 +69,11 @@ describe('aktivitaeten/load — core', () => {
|
|||||||
|
|
||||||
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
|
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
|
||||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||||
const validResult = await load({
|
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl('?filter=fuer-dich')
|
|
||||||
} as never);
|
|
||||||
expect(validResult.filter).toBe('fuer-dich');
|
expect(validResult.filter).toBe('fuer-dich');
|
||||||
|
|
||||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||||
const invalidResult = await load({
|
const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl('?filter=bogus')
|
|
||||||
} as never);
|
|
||||||
expect(invalidResult.filter).toBe('alle');
|
expect(invalidResult.filter).toBe('alle');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -101,11 +81,7 @@ describe('aktivitaeten/load — core', () => {
|
|||||||
describe('aktivitaeten/load — kinds param per filter', () => {
|
describe('aktivitaeten/load — kinds param per filter', () => {
|
||||||
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
|
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
|
||||||
mockSuccess();
|
mockSuccess();
|
||||||
await load({
|
await load({ fetch, url: buildUrl() } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl()
|
|
||||||
} as never);
|
|
||||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||||
params: { query: { limit: 40 } }
|
params: { query: { limit: 40 } }
|
||||||
});
|
});
|
||||||
@@ -113,11 +89,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
|
|
||||||
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
|
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
|
||||||
mockSuccess();
|
mockSuccess();
|
||||||
await load({
|
await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl('?filter=fuer-dich')
|
|
||||||
} as never);
|
|
||||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||||
params: { query: { limit: 40 } }
|
params: { query: { limit: 40 } }
|
||||||
});
|
});
|
||||||
@@ -125,11 +97,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
|
|
||||||
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
|
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
|
||||||
mockSuccess();
|
mockSuccess();
|
||||||
await load({
|
await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl('?filter=hochgeladen')
|
|
||||||
} as never);
|
|
||||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||||
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
|
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
|
||||||
});
|
});
|
||||||
@@ -137,11 +105,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
|
|
||||||
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
|
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
|
||||||
mockSuccess();
|
mockSuccess();
|
||||||
await load({
|
await load({ fetch, url: buildUrl('?filter=transkription') } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl('?filter=transkription')
|
|
||||||
} as never);
|
|
||||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
@@ -156,11 +120,7 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
|
|
||||||
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
|
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
|
||||||
mockSuccess();
|
mockSuccess();
|
||||||
await load({
|
await load({ fetch, url: buildUrl('?filter=kommentare') } as never);
|
||||||
fetch,
|
|
||||||
request: new Request('http://localhost/aktivitaeten'),
|
|
||||||
url: buildUrl('?filter=kommentare')
|
|
||||||
} as never);
|
|
||||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ describe('korrespondenz load — no senderId', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/briefwechsel'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
locals: { user: readUser }
|
locals: { user: readUser }
|
||||||
});
|
});
|
||||||
@@ -70,7 +69,6 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: 'p1' }),
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
request: new Request('http://localhost/briefwechsel'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
locals: { user: readUser }
|
locals: { user: readUser }
|
||||||
});
|
});
|
||||||
@@ -110,7 +108,6 @@ describe('korrespondenz load — senderId and receiverId set', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
|
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
|
||||||
request: new Request('http://localhost/briefwechsel'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
locals: { user: readUser }
|
locals: { user: readUser }
|
||||||
});
|
});
|
||||||
@@ -140,7 +137,6 @@ describe('korrespondenz load — canWrite', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: 'p1' }),
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
request: new Request('http://localhost/briefwechsel'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
locals: { user: writeUser }
|
locals: { user: writeUser }
|
||||||
});
|
});
|
||||||
@@ -164,7 +160,6 @@ describe('korrespondenz load — canWrite', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: 'p1' }),
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
request: new Request('http://localhost/briefwechsel'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
locals: { user: readUser }
|
locals: { user: readUser }
|
||||||
});
|
});
|
||||||
@@ -193,7 +188,6 @@ describe('korrespondenz load — backend error', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
load({
|
load({
|
||||||
url: makeUrl({ senderId: 'p1' }),
|
url: makeUrl({ senderId: 'p1' }),
|
||||||
request: new Request('http://localhost/briefwechsel'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
locals: { user: readUser }
|
locals: { user: readUser }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ describe('document detail load — happy path', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: '123' },
|
params: { id: '123' },
|
||||||
fetch: mockFetch as unknown as typeof fetch,
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
request: new Request('http://localhost/documents/123'),
|
|
||||||
url: new URL('http://localhost/documents/123')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.document.title).toBe('Testbrief');
|
expect(result.document.title).toBe('Testbrief');
|
||||||
@@ -46,12 +44,7 @@ describe('document detail load — error paths', () => {
|
|||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
params: { id: 'missing' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/documents/123'),
|
|
||||||
url: new URL('http://localhost/documents/123')
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ status: 404 });
|
).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,12 +59,7 @@ describe('document detail load — error paths', () => {
|
|||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
params: { id: 'secret' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/documents/123'),
|
|
||||||
url: new URL('http://localhost/documents/123')
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,12 +74,7 @@ describe('document detail load — error paths', () => {
|
|||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
params: { id: 'any' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch,
|
|
||||||
request: new Request('http://localhost/documents/123'),
|
|
||||||
url: new URL('http://localhost/documents/123')
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ location: '/login' });
|
).rejects.toMatchObject({ location: '/login' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
|||||||
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
|
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error — partial event shape sufficient for this guard
|
// @ts-expect-error — partial event shape sufficient for this guard
|
||||||
await load({
|
await load({ locals });
|
||||||
locals,
|
|
||||||
request: new Request('http://localhost/documents/bulk-edit'),
|
|
||||||
url: new URL('http://localhost/documents/bulk-edit')
|
|
||||||
});
|
|
||||||
throw new Error('expected redirect to be thrown');
|
throw new Error('expected redirect to be thrown');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as { status?: number; location?: string };
|
const err = e as { status?: number; location?: string };
|
||||||
@@ -23,11 +19,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
|||||||
const locals = { user: { groups: [] } };
|
const locals = { user: { groups: [] } };
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error — partial event shape sufficient for this guard
|
// @ts-expect-error — partial event shape sufficient for this guard
|
||||||
await load({
|
await load({ locals });
|
||||||
locals,
|
|
||||||
request: new Request('http://localhost/documents/bulk-edit'),
|
|
||||||
url: new URL('http://localhost/documents/bulk-edit')
|
|
||||||
});
|
|
||||||
throw new Error('expected redirect');
|
throw new Error('expected redirect');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect((e as { status?: number }).status).toBe(303);
|
expect((e as { status?: number }).status).toBe(303);
|
||||||
@@ -38,11 +30,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
|||||||
const locals = {};
|
const locals = {};
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error — partial event shape sufficient for this guard
|
// @ts-expect-error — partial event shape sufficient for this guard
|
||||||
await load({
|
await load({ locals });
|
||||||
locals,
|
|
||||||
request: new Request('http://localhost/documents/bulk-edit'),
|
|
||||||
url: new URL('http://localhost/documents/bulk-edit')
|
|
||||||
});
|
|
||||||
throw new Error('expected redirect');
|
throw new Error('expected redirect');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect((e as { status?: number }).status).toBe(303);
|
expect((e as { status?: number }).status).toBe(303);
|
||||||
@@ -52,11 +40,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
|||||||
it('returns canWrite=true for a WRITE_ALL user', async () => {
|
it('returns canWrite=true for a WRITE_ALL user', async () => {
|
||||||
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
|
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
|
||||||
// @ts-expect-error — partial event shape sufficient for this guard
|
// @ts-expect-error — partial event shape sufficient for this guard
|
||||||
const result = await load({
|
const result = await load({ locals });
|
||||||
locals,
|
|
||||||
request: new Request('http://localhost/documents/bulk-edit'),
|
|
||||||
url: new URL('http://localhost/documents/bulk-edit')
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ canWrite: true });
|
expect(result).toEqual({ canWrite: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,11 +52,7 @@ describe('/documents/bulk-edit +page.server.ts', () => {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error — partial event shape sufficient for this guard
|
// @ts-expect-error — partial event shape sufficient for this guard
|
||||||
await load({
|
await load({ locals });
|
||||||
locals,
|
|
||||||
request: new Request('http://localhost/documents/bulk-edit'),
|
|
||||||
url: new URL('http://localhost/documents/bulk-edit')
|
|
||||||
});
|
|
||||||
throw new Error('expected redirect');
|
throw new Error('expected redirect');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect((e as { status?: number }).status).toBe(303);
|
expect((e as { status?: number }).status).toBe(303);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ describe('documents page load — search params', () => {
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }),
|
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', to: '1950-12-31' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +57,6 @@ describe('documents page load — search params', () => {
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }),
|
url: makeUrl({ senderId: 'p-1', receiverId: 'p-2' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +81,6 @@ describe('documents page load — search params', () => {
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
url: makeUrl({ sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +111,6 @@ describe('documents page load — search params', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ q: 'test' }),
|
url: makeUrl({ q: 'test' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +129,6 @@ describe('documents page load — search params', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }),
|
url: makeUrl({ q: 'Urlaub', from: '1920-01-01', sort: 'TITLE', dir: 'asc' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,11 +148,7 @@ describe('documents page load — auth redirect', () => {
|
|||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||||
url: makeUrl(),
|
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ location: '/login' });
|
).rejects.toMatchObject({ location: '/login' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -170,11 +161,7 @@ describe('documents page load — network error fallback', () => {
|
|||||||
GET: vi.fn().mockRejectedValue(new Error('Network failure'))
|
GET: vi.fn().mockRejectedValue(new Error('Network failure'))
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
url: makeUrl(),
|
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.error).toBeTruthy();
|
expect(result.error).toBeTruthy();
|
||||||
expect(result.items).toEqual([]);
|
expect(result.items).toEqual([]);
|
||||||
@@ -212,7 +199,6 @@ describe('documents page load — person name resolution', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,7 +210,6 @@ describe('documents page load — person name resolution', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
|
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +221,6 @@ describe('documents page load — person name resolution', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: 'not-a-uuid' }),
|
url: makeUrl({ senderId: 'not-a-uuid' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +234,6 @@ describe('documents page load — person name resolution', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||||
request: new Request('http://localhost/documents'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ it('never calls /api/documents/search regardless of URL params', async () => {
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -50,7 +49,6 @@ it('always fetches dashboard data regardless of URL params', async () => {
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl({ q: 'Urlaub' }),
|
url: makeUrl({ q: 'Urlaub' }),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -112,7 +110,6 @@ describe('home page load — dashboard', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -150,7 +147,6 @@ describe('home page load — dashboard', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -172,7 +168,6 @@ describe('home page load — dashboard', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -194,7 +189,6 @@ describe('home page load — dashboard', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -219,7 +213,6 @@ describe('home page load — dashboard', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -239,7 +232,6 @@ describe('home page load — auth redirect', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
load({
|
load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0])
|
} as Parameters<typeof load>[0])
|
||||||
@@ -257,7 +249,6 @@ describe('home page load — network error fallback', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: contributorParent()
|
parent: contributorParent()
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -277,7 +268,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi
|
parent: vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -299,7 +289,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi
|
parent: vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -321,7 +310,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi
|
parent: vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -344,7 +332,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true })
|
parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true })
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -365,7 +352,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi
|
parent: vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -383,7 +369,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false })
|
parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false })
|
||||||
} as Parameters<typeof load>[0]);
|
} as Parameters<typeof load>[0]);
|
||||||
@@ -413,7 +398,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
url: makeUrl(),
|
url: makeUrl(),
|
||||||
request: new Request('http://localhost/'),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch,
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
parent: vi
|
parent: vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -32,13 +32,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||||
params: { id: 'p1' },
|
|
||||||
fetch: mockFetch,
|
|
||||||
request: new Request('http://localhost/persons/p1'),
|
|
||||||
url: new URL('http://localhost/persons/p1'),
|
|
||||||
locals: mockLocals
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.person.firstName).toBe('Hans');
|
expect(result.person.firstName).toBe('Hans');
|
||||||
expect(result.sentDocuments).toHaveLength(1);
|
expect(result.sentDocuments).toHaveLength(1);
|
||||||
@@ -61,13 +55,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
||||||
params: { id: 'p1' },
|
|
||||||
fetch: mockFetch,
|
|
||||||
request: new Request('http://localhost/persons/p1'),
|
|
||||||
url: new URL('http://localhost/persons/p1'),
|
|
||||||
locals: mockLocalsWriter
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.canWrite).toBe(true);
|
expect(result.canWrite).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -88,13 +76,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||||
params: { id: 'p1' },
|
|
||||||
fetch: mockFetch,
|
|
||||||
request: new Request('http://localhost/persons/p1'),
|
|
||||||
url: new URL('http://localhost/persons/p1'),
|
|
||||||
locals: mockLocals
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sentDocuments).toEqual([]);
|
expect(result.sentDocuments).toEqual([]);
|
||||||
expect(result.receivedDocuments).toEqual([]);
|
expect(result.receivedDocuments).toEqual([]);
|
||||||
@@ -118,13 +100,7 @@ describe('person detail load — error paths', () => {
|
|||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals })
|
||||||
params: { id: 'missing' },
|
|
||||||
fetch: mockFetch,
|
|
||||||
request: new Request('http://localhost/persons/p1'),
|
|
||||||
url: new URL('http://localhost/persons/p1'),
|
|
||||||
locals: mockLocals
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
status: 404
|
status: 404
|
||||||
});
|
});
|
||||||
@@ -144,13 +120,7 @@ describe('person detail load — error paths', () => {
|
|||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({
|
load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals })
|
||||||
params: { id: 'forbidden' },
|
|
||||||
fetch: mockFetch,
|
|
||||||
request: new Request('http://localhost/persons/p1'),
|
|
||||||
url: new URL('http://localhost/persons/p1'),
|
|
||||||
locals: mockLocals
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
status: 403
|
status: 403
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user