feat: Person name aliases — support name changes over time #181 #206
@@ -5,10 +5,12 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
@@ -92,4 +94,24 @@ public class PersonController {
|
||||
}
|
||||
personService.mergePersons(id, UUID.fromString(targetIdStr));
|
||||
}
|
||||
|
||||
// ─── Alias endpoints ────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/{id}/aliases")
|
||||
public List<PersonNameAlias> getAliases(@PathVariable UUID id) {
|
||||
return personService.getAliases(id);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/aliases")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public PersonNameAlias addAlias(@PathVariable UUID id, @Valid @RequestBody PersonNameAliasDTO dto) {
|
||||
return personService.addAlias(id, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/aliases/{aliasId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public void removeAlias(@PathVariable UUID id, @PathVariable UUID aliasId) {
|
||||
personService.removeAlias(id, aliasId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
|
||||
public record PersonNameAliasDTO(
|
||||
@NotBlank @Size(max = 255) String lastName,
|
||||
@Size(max = 255) String firstName,
|
||||
@NotNull PersonNameAliasType type
|
||||
) {}
|
||||
@@ -11,6 +11,8 @@ public enum ErrorCode {
|
||||
// --- Persons ---
|
||||
/** A person with the given ID does not exist. 404 */
|
||||
PERSON_NOT_FOUND,
|
||||
/** A person name alias with the given ID does not exist. 404 */
|
||||
ALIAS_NOT_FOUND,
|
||||
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@Entity
|
||||
@Table(name = "persons")
|
||||
@@ -35,4 +38,12 @@ public class Person {
|
||||
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
|
||||
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||
// separate DB roundtrip while respecting domain boundaries.
|
||||
@OneToMany(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonIgnore
|
||||
@Builder.Default
|
||||
private List<PersonNameAlias> nameAliases = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "person_name_aliases")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PersonNameAlias {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "person_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Person person;
|
||||
|
||||
@Column(name = "last_name", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String lastName;
|
||||
|
||||
@Column(name = "first_name")
|
||||
private String firstName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private PersonNameAliasType type;
|
||||
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer sortOrder;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Instant createdAt;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
public enum PersonNameAliasType {
|
||||
BIRTH,
|
||||
WIDOWED,
|
||||
DIVORCED,
|
||||
OTHER
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.UUID;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -25,6 +26,12 @@ public class DocumentSpecifications {
|
||||
// LEFT JOIN on sender (ManyToOne — no duplicate rows)
|
||||
Join<Document, Person> senderJoin = root.join("sender", JoinType.LEFT);
|
||||
|
||||
// LEFT JOIN sender → aliases (entity-graph navigation avoids a separate DB
|
||||
// roundtrip while respecting domain boundaries — the alias table is part of
|
||||
// the Person aggregate, navigated via @OneToMany, not via a cross-domain
|
||||
// repository call from DocumentService)
|
||||
Join<Person, PersonNameAlias> senderAliasJoin = senderJoin.join("nameAliases", JoinType.LEFT);
|
||||
|
||||
// EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs
|
||||
Subquery<Long> receiverSub = query.subquery(Long.class);
|
||||
Root<Document> receiverRoot = receiverSub.from(Document.class);
|
||||
@@ -38,6 +45,17 @@ public class DocumentSpecifications {
|
||||
)
|
||||
);
|
||||
|
||||
// EXISTS subquery for receiver alias name
|
||||
Subquery<Long> receiverAliasSub = query.subquery(Long.class);
|
||||
Root<Document> receiverAliasRoot = receiverAliasSub.from(Document.class);
|
||||
Join<Document, Person> recAliasPersonJoin = receiverAliasRoot.join("receivers");
|
||||
Join<Person, PersonNameAlias> recAliasJoin = recAliasPersonJoin.join("nameAliases");
|
||||
receiverAliasSub.select(cb.literal(1L))
|
||||
.where(
|
||||
cb.equal(receiverAliasRoot.get("id"), root.get("id")),
|
||||
cb.like(cb.lower(recAliasJoin.get("lastName")), likePattern)
|
||||
);
|
||||
|
||||
// EXISTS subquery for tag name — avoids duplicate rows for multi-tag docs
|
||||
Subquery<Long> tagSub = query.subquery(Long.class);
|
||||
Root<Document> tagRoot = tagSub.from(Document.class);
|
||||
@@ -57,7 +75,9 @@ public class DocumentSpecifications {
|
||||
cb.like(cb.lower(root.get("location")), likePattern),
|
||||
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
|
||||
cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
|
||||
cb.like(cb.lower(senderAliasJoin.get("lastName")), likePattern),
|
||||
cb.exists(receiverSub),
|
||||
cb.exists(receiverAliasSub),
|
||||
cb.exists(tagSub)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PersonNameAliasRepository extends JpaRepository<PersonNameAlias, UUID> {
|
||||
|
||||
List<PersonNameAlias> findByPersonIdOrderBySortOrderAscCreatedAtAsc(UUID personId);
|
||||
|
||||
@Query("SELECT COALESCE(MAX(a.sortOrder), -1) FROM PersonNameAlias a WHERE a.person.id = :personId")
|
||||
int findMaxSortOrder(UUID personId);
|
||||
}
|
||||
@@ -15,11 +15,11 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
|
||||
// Suche nach String in Vor- ODER Nachnamen, sortiert nach Nachname
|
||||
@Query("SELECT p FROM Person p WHERE " +
|
||||
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN p.nameAliases a WHERE " +
|
||||
"LOWER(CONCAT(p.firstName,' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', p.firstName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
List<Person> searchByName(@Param("query") String query);
|
||||
|
||||
@@ -51,9 +51,12 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
FROM persons p
|
||||
LEFT JOIN person_name_aliases a ON a.person_id = p.id
|
||||
WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
GROUP BY p.id, p.first_name, p.last_name, p.alias, p.birth_year, p.death_year, p.notes
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
|
||||
@@ -4,11 +4,14 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -22,6 +25,7 @@ import lombok.RequiredArgsConstructor;
|
||||
public class PersonService {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
|
||||
public List<PersonSummaryDTO> findAll(String q) {
|
||||
if (q == null) {
|
||||
@@ -137,4 +141,35 @@ public class PersonService {
|
||||
|
||||
personRepository.deleteById(sourceId);
|
||||
}
|
||||
|
||||
// ─── Alias management ───────────────────────────────────────────────────
|
||||
|
||||
public List<PersonNameAlias> getAliases(UUID personId) {
|
||||
getById(personId);
|
||||
return aliasRepository.findByPersonIdOrderBySortOrderAscCreatedAtAsc(personId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PersonNameAlias addAlias(UUID personId, PersonNameAliasDTO dto) {
|
||||
Person person = getById(personId);
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(personId) + 1;
|
||||
PersonNameAlias alias = PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(dto.lastName())
|
||||
.firstName(dto.firstName())
|
||||
.type(dto.type())
|
||||
.sortOrder(nextSortOrder)
|
||||
.build();
|
||||
return aliasRepository.save(alias);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeAlias(UUID personId, UUID aliasId) {
|
||||
PersonNameAlias alias = aliasRepository.findById(aliasId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.ALIAS_NOT_FOUND, "Alias not found: " + aliasId));
|
||||
if (!alias.getPerson().getId().equals(personId)) {
|
||||
throw DomainException.forbidden("Alias does not belong to this person");
|
||||
}
|
||||
aliasRepository.delete(alias);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Enable pg_trgm for substring search via GIN indexes
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- Historical name aliases for persons (marriage, widowhood, etc.)
|
||||
CREATE TABLE person_name_aliases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||
last_name VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Indexes on alias table
|
||||
CREATE INDEX idx_aliases_person_id ON person_name_aliases(person_id);
|
||||
CREATE INDEX idx_aliases_last_name_trgm ON person_name_aliases USING GIN (lower(last_name) gin_trgm_ops);
|
||||
|
||||
-- Retroactive GIN trigram indexes on existing persons table for substring search
|
||||
CREATE INDEX idx_persons_first_name_trgm ON persons USING GIN (lower(first_name) gin_trgm_ops);
|
||||
CREATE INDEX idx_persons_last_name_trgm ON persons USING GIN (lower(last_name) gin_trgm_ops);
|
||||
CREATE INDEX idx_persons_alias_trgm ON persons USING GIN (lower(alias) gin_trgm_ops);
|
||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.controller;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
@@ -25,6 +27,7 @@ import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -393,4 +396,84 @@ class PersonControllerTest {
|
||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── GET /api/persons/{id}/aliases ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getAliases_returns200_withList() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
PersonNameAlias alias = PersonNameAlias.builder()
|
||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||
when(personService.getAliases(personId)).thenReturn(List.of(alias));
|
||||
|
||||
mockMvc.perform(get("/api/persons/{id}/aliases", personId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].lastName").value("de Gruyter"));
|
||||
}
|
||||
|
||||
// ─── POST /api/persons/{id}/aliases ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns200_whenValid() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
PersonNameAlias saved = PersonNameAlias.builder()
|
||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("de Gruyter"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void addAlias_returns403_withoutWritePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/persons/{id}/aliases/{aliasId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void removeAlias_returns204_whenValid() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
UUID aliasId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(personService).removeAlias(personId, aliasId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
@@ -28,6 +30,7 @@ class DocumentSpecificationsTest {
|
||||
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired PersonNameAliasRepository aliasRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
|
||||
private Person sender;
|
||||
@@ -325,4 +328,27 @@ class DocumentSpecificationsTest {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── hasText with aliases ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void hasText_findsDocumentBySenderAliasLastName() {
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(sender).lastName("von Mueller").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasText("von Mueller")));
|
||||
|
||||
assertThat(result).isNotEmpty();
|
||||
assertThat(result).extracting(Document::getTitle).contains("Alter Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasText_findsDocumentByReceiverAliasLastName() {
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(receiver).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasText("de Gruyter")));
|
||||
|
||||
assertThat(result).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
@@ -29,6 +31,9 @@ class PersonRepositoryTest {
|
||||
@Autowired
|
||||
private PersonRepository personRepository;
|
||||
|
||||
@Autowired
|
||||
private PersonNameAliasRepository aliasRepository;
|
||||
|
||||
@Autowired
|
||||
private DocumentRepository documentRepository;
|
||||
|
||||
@@ -383,4 +388,56 @@ class PersonRepositoryTest {
|
||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── searchByName with aliases ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchByName_findsByAliasLastName() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("de Gruyter");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_stillFindsByCurrentLastName_afterAliasAdded() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Cram");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_doesNotReturnDuplicates_whenMultipleAliasesMatch() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("Gruyter-Cram").type(PersonNameAliasType.OTHER).sortOrder(1).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Gruyter");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_findsByAliasLastName() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<PersonSummaryDTO> results = personRepository.searchWithDocumentCount("de Gruyter");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -25,6 +29,7 @@ import static org.mockito.Mockito.*;
|
||||
class PersonServiceTest {
|
||||
|
||||
@Mock PersonRepository personRepository;
|
||||
@Mock PersonNameAliasRepository aliasRepository;
|
||||
@InjectMocks PersonService personService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
@@ -436,4 +441,99 @@ class PersonServiceTest {
|
||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||
verify(personRepository).deleteById(sourceId);
|
||||
}
|
||||
|
||||
// ─── getAliases ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getAliases_returnsSortedAliases() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(personRepository.findById(personId)).thenReturn(Optional.of(
|
||||
Person.builder().id(personId).firstName("Clara").lastName("Cram").build()));
|
||||
List<PersonNameAlias> aliases = List.of(
|
||||
PersonNameAlias.builder().id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
when(aliasRepository.findByPersonIdOrderBySortOrderAscCreatedAtAsc(personId)).thenReturn(aliases);
|
||||
|
||||
List<PersonNameAlias> result = personService.getAliases(personId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getLastName()).isEqualTo("de Gruyter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAliases_throwsNotFound_whenPersonMissing() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(personRepository.findById(personId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.getAliases(personId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── addAlias ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void addAlias_savesWithAutoIncrementedSortOrder() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
Person person = Person.builder().id(personId).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findById(personId)).thenReturn(Optional.of(person));
|
||||
when(aliasRepository.findMaxSortOrder(personId)).thenReturn(2);
|
||||
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonNameAliasDTO dto = new PersonNameAliasDTO("de Gruyter", null, PersonNameAliasType.BIRTH);
|
||||
PersonNameAlias result = personService.addAlias(personId, dto);
|
||||
|
||||
assertThat(result.getSortOrder()).isEqualTo(3);
|
||||
assertThat(result.getLastName()).isEqualTo("de Gruyter");
|
||||
assertThat(result.getPerson()).isEqualTo(person);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAlias_throwsNotFound_whenPersonMissing() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(personRepository.findById(personId)).thenReturn(Optional.empty());
|
||||
|
||||
PersonNameAliasDTO dto = new PersonNameAliasDTO("de Gruyter", null, PersonNameAliasType.BIRTH);
|
||||
|
||||
assertThatThrownBy(() -> personService.addAlias(personId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── removeAlias ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void removeAlias_deletesAlias_whenBelongsToPerson() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
UUID aliasId = UUID.randomUUID();
|
||||
Person person = Person.builder().id(personId).firstName("Clara").lastName("Cram").build();
|
||||
PersonNameAlias alias = PersonNameAlias.builder().id(aliasId).person(person).lastName("de Gruyter").build();
|
||||
when(aliasRepository.findById(aliasId)).thenReturn(Optional.of(alias));
|
||||
|
||||
personService.removeAlias(personId, aliasId);
|
||||
|
||||
verify(aliasRepository).delete(alias);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeAlias_throwsNotFound_whenAliasMissing() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
UUID aliasId = UUID.randomUUID();
|
||||
when(aliasRepository.findById(aliasId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.removeAlias(personId, aliasId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeAlias_throwsForbidden_whenAliasDoesNotBelongToPerson() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
UUID otherPersonId = UUID.randomUUID();
|
||||
UUID aliasId = UUID.randomUUID();
|
||||
Person otherPerson = Person.builder().id(otherPersonId).firstName("Other").lastName("Person").build();
|
||||
PersonNameAlias alias = PersonNameAlias.builder().id(aliasId).person(otherPerson).lastName("de Gruyter").build();
|
||||
when(aliasRepository.findById(aliasId)).thenReturn(Optional.of(alias));
|
||||
|
||||
assertThatThrownBy(() -> personService.removeAlias(personId, aliasId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,5 +479,20 @@
|
||||
"scan_collapse": "Scan verkleinern",
|
||||
"transcription_empty_title": "Noch keine Transkription",
|
||||
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||
"transcription_panel_close": "Panel schließen"
|
||||
"transcription_panel_close": "Panel schließen",
|
||||
"person_alias_heading": "Namensverlauf",
|
||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||
"person_alias_type_BIRTH": "geborene/r",
|
||||
"person_alias_type_WIDOWED": "verwitwete/r",
|
||||
"person_alias_type_DIVORCED": "geschiedene/r",
|
||||
"person_alias_type_OTHER": "Sonstiger Name",
|
||||
"person_alias_add_heading": "Name hinzufuegen",
|
||||
"person_alias_label_type": "Art",
|
||||
"person_alias_label_last_name": "Nachname",
|
||||
"person_alias_label_first_name": "Vorname (optional)",
|
||||
"person_alias_btn_add": "Hinzufuegen",
|
||||
"person_alias_delete_title": "Alias entfernen?",
|
||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden."
|
||||
}
|
||||
|
||||
@@ -479,5 +479,20 @@
|
||||
"scan_collapse": "Collapse scan",
|
||||
"transcription_empty_title": "No transcription yet",
|
||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||
"transcription_panel_close": "Close panel"
|
||||
"transcription_panel_close": "Close panel",
|
||||
"person_alias_heading": "Name history",
|
||||
"person_alias_empty": "No name changes recorded yet.",
|
||||
"person_alias_type_BIRTH": "Birth name",
|
||||
"person_alias_type_WIDOWED": "Name as widow/widower",
|
||||
"person_alias_type_DIVORCED": "Name after divorce",
|
||||
"person_alias_type_OTHER": "Other name",
|
||||
"person_alias_add_heading": "Add name",
|
||||
"person_alias_label_type": "Type",
|
||||
"person_alias_label_last_name": "Last name",
|
||||
"person_alias_label_first_name": "First name (optional)",
|
||||
"person_alias_btn_add": "Add",
|
||||
"person_alias_delete_title": "Remove alias?",
|
||||
"person_alias_delete_body": "This name will be removed from search results.",
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found."
|
||||
}
|
||||
|
||||
@@ -479,5 +479,20 @@
|
||||
"scan_collapse": "Reducir escaneo",
|
||||
"transcription_empty_title": "Sin transcripcion",
|
||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||
"transcription_panel_close": "Cerrar panel"
|
||||
"transcription_panel_close": "Cerrar panel",
|
||||
"person_alias_heading": "Historial de nombres",
|
||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||
"person_alias_type_BIRTH": "Nombre de nacimiento",
|
||||
"person_alias_type_WIDOWED": "Nombre como viuda/viudo",
|
||||
"person_alias_type_DIVORCED": "Nombre tras el divorcio",
|
||||
"person_alias_type_OTHER": "Otro nombre",
|
||||
"person_alias_add_heading": "Agregar nombre",
|
||||
"person_alias_label_type": "Tipo",
|
||||
"person_alias_label_last_name": "Apellido",
|
||||
"person_alias_label_first_name": "Nombre (opcional)",
|
||||
"person_alias_btn_add": "Agregar",
|
||||
"person_alias_delete_title": "Eliminar alias?",
|
||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'PERSON_NOT_FOUND'
|
||||
| 'ALIAS_NOT_FOUND'
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
@@ -52,6 +53,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
switch (code) {
|
||||
case 'PERSON_NOT_FOUND':
|
||||
return m.error_person_not_found();
|
||||
case 'ALIAS_NOT_FOUND':
|
||||
return m.error_alias_not_found();
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
|
||||
@@ -196,6 +196,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/aliases": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getAliases"];
|
||||
put?: never;
|
||||
post: operations["addAlias"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications/read-all": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -836,6 +852,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/aliases/{aliasId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["removeAlias"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1014,6 +1046,25 @@ export interface components {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
PersonNameAliasDTO: {
|
||||
lastName?: string;
|
||||
firstName?: string;
|
||||
/** @enum {string} */
|
||||
type?: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
|
||||
};
|
||||
PersonNameAlias: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
person?: components["schemas"]["Person"];
|
||||
lastName: string;
|
||||
firstName?: string;
|
||||
/** @enum {string} */
|
||||
type: "BIRTH" | "WIDOWED" | "DIVORCED" | "OTHER";
|
||||
/** Format: int32 */
|
||||
sortOrder: number;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
GroupDTO: {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
@@ -1177,10 +1228,10 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
number?: number;
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -1809,6 +1860,54 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getAliases: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["PersonNameAlias"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
addAlias: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PersonNameAliasDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["PersonNameAlias"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
markAllRead: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2924,6 +3023,27 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
removeAlias: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
aliasId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteAnnotation: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -11,10 +11,11 @@ export async function load({ params, fetch, locals }) {
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
|
||||
const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([
|
||||
const [personResult, sentDocsResult, receivedDocsResult, aliasesResult] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } })
|
||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } })
|
||||
]);
|
||||
|
||||
if (!personResult.response.ok) {
|
||||
@@ -26,6 +27,7 @@ export async function load({ params, fetch, locals }) {
|
||||
person: personResult.data!,
|
||||
sentDocuments: sentDocsResult.data ?? [],
|
||||
receivedDocuments: receivedDocsResult.data ?? [],
|
||||
aliases: aliasesResult.data ?? [],
|
||||
canWrite
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import PersonCard from './PersonCard.svelte';
|
||||
import NameHistoryCard from './NameHistoryCard.svelte';
|
||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||
|
||||
@@ -65,9 +66,12 @@ const coCorrespondents = $derived.by(() => {
|
||||
|
||||
<!-- 2-column layout on large screens -->
|
||||
<div class="lg:grid lg:grid-cols-[35%_65%] lg:gap-8">
|
||||
<!-- Left column: Person card -->
|
||||
<!-- Left column: Person card + name history -->
|
||||
<div>
|
||||
<PersonCard person={person} canWrite={data.canWrite} />
|
||||
<div class="mt-6">
|
||||
<NameHistoryCard aliases={data.aliases} personFirstName={person.firstName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: correspondents + documents -->
|
||||
|
||||
53
frontend/src/routes/persons/[id]/NameHistoryCard.svelte
Normal file
53
frontend/src/routes/persons/[id]/NameHistoryCard.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
aliases: Array<{
|
||||
id: string;
|
||||
lastName: string;
|
||||
firstName?: string | null;
|
||||
type: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
personFirstName: string;
|
||||
}
|
||||
|
||||
let { aliases, personFirstName }: Props = $props();
|
||||
|
||||
let sorted = $derived([...aliases].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'BIRTH':
|
||||
return m.person_alias_type_BIRTH();
|
||||
case 'WIDOWED':
|
||||
return m.person_alias_type_WIDOWED();
|
||||
case 'DIVORCED':
|
||||
return m.person_alias_type_DIVORCED();
|
||||
default:
|
||||
return m.person_alias_type_OTHER();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.person_alias_heading()}
|
||||
</h2>
|
||||
|
||||
{#if sorted.length === 0}
|
||||
<p class="text-sm text-ink-2 italic">{m.person_alias_empty()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sorted as alias (alias.id)}
|
||||
<li>
|
||||
<span class="text-ink-2 italic">{typeLabel(alias.type)}</span>
|
||||
<span class="font-serif text-ink">
|
||||
{alias.firstName ?? personFirstName}
|
||||
{alias.lastName}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import NameHistoryCard from './NameHistoryCard.svelte';
|
||||
|
||||
const aliases = [
|
||||
{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 },
|
||||
{ id: 'a2', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 1 }
|
||||
];
|
||||
|
||||
describe('NameHistoryCard', () => {
|
||||
it('should render one row per alias', async () => {
|
||||
render(NameHistoryCard, { aliases, personFirstName: 'Clara' });
|
||||
|
||||
await expect.element(page.getByText('de Gruyter')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no aliases', async () => {
|
||||
render(NameHistoryCard, { aliases: [], personFirstName: 'Clara' });
|
||||
|
||||
const emptyText = document.querySelector('.italic');
|
||||
expect(emptyText).not.toBeNull();
|
||||
expect(emptyText!.textContent!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use personFirstName when alias firstName is null', async () => {
|
||||
render(NameHistoryCard, {
|
||||
aliases: [{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }],
|
||||
personFirstName: 'Clara'
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Clara')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alias firstName when provided', async () => {
|
||||
render(NameHistoryCard, {
|
||||
aliases: [
|
||||
{ id: 'a1', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 0 }
|
||||
],
|
||||
personFirstName: 'Clara'
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Maria')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show type labels', async () => {
|
||||
render(NameHistoryCard, {
|
||||
aliases: [{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }],
|
||||
personFirstName: 'Clara'
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('geborene/r')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,17 @@ export async function load({ params, fetch, locals }) {
|
||||
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||
const [result, aliasesResult] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } })
|
||||
]);
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { person: result.data! };
|
||||
return { person: result.data!, aliases: aliasesResult.data ?? [] };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -83,5 +86,53 @@ export const actions = {
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${targetPersonId}`);
|
||||
},
|
||||
|
||||
addAlias: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const firstName = formData.get('firstName')?.toString().trim() || undefined;
|
||||
const type = formData.get('type')?.toString();
|
||||
|
||||
if (!lastName) {
|
||||
return fail(400, { aliasError: 'Nachname ist ein Pflichtfeld.' });
|
||||
}
|
||||
if (!type) {
|
||||
return fail(400, { aliasError: 'Art ist ein Pflichtfeld.' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/persons/{id}/aliases', {
|
||||
params: { path: { id: params.id } },
|
||||
body: { lastName, firstName, type: type as 'BIRTH' | 'WIDOWED' | 'DIVORCED' | 'OTHER' }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { aliasError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { aliasSuccess: true };
|
||||
},
|
||||
|
||||
removeAlias: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const aliasId = formData.get('aliasId')?.toString();
|
||||
|
||||
if (!aliasId) {
|
||||
return fail(400, { aliasError: 'Alias ID fehlt.' });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.DELETE('/api/persons/{id}/aliases/{aliasId}', {
|
||||
params: { path: { id: params.id, aliasId } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { aliasError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { aliasSuccess: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import PersonEditForm from './PersonEditForm.svelte';
|
||||
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
||||
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
||||
import PersonDangerZone from './PersonDangerZone.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -41,16 +42,18 @@ const person = $derived(data.person);
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<form id="person-edit-form" method="POST" use:enhance>
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.persons_section_details()}
|
||||
</h2>
|
||||
<PersonEditForm person={person} />
|
||||
</div>
|
||||
|
||||
<PersonDangerZone person={person} form={form} />
|
||||
|
||||
<PersonEditSaveBar discardHref="/persons/{person.id}" />
|
||||
</form>
|
||||
|
||||
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
||||
|
||||
<PersonDangerZone person={person} form={form} />
|
||||
|
||||
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
|
||||
</div>
|
||||
|
||||
185
frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte
Normal file
185
frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
aliases: Array<{
|
||||
id: string;
|
||||
lastName: string;
|
||||
firstName?: string | null;
|
||||
type: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
canWrite: boolean;
|
||||
aliasError?: string | null;
|
||||
}
|
||||
|
||||
let { aliases, canWrite, aliasError = null }: Props = $props();
|
||||
|
||||
let sorted = $derived([...aliases].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
let showDeleteModal = $state(false);
|
||||
let deleteTargetId: string | null = $state(null);
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'BIRTH':
|
||||
return m.person_alias_type_BIRTH();
|
||||
case 'WIDOWED':
|
||||
return m.person_alias_type_WIDOWED();
|
||||
case 'DIVORCED':
|
||||
return m.person_alias_type_DIVORCED();
|
||||
default:
|
||||
return m.person_alias_type_OTHER();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: string) {
|
||||
deleteTargetId = id;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.person_alias_heading()}
|
||||
</h2>
|
||||
|
||||
{#if aliasError}
|
||||
<p class="mb-3 text-sm text-red-600">{aliasError}</p>
|
||||
{/if}
|
||||
|
||||
{#if sorted.length === 0}
|
||||
<p class="text-sm text-ink-2 italic">{m.person_alias_empty()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sorted as alias (alias.id)}
|
||||
<li class="flex items-center justify-between">
|
||||
<span>
|
||||
<span class="text-ink-2 italic">{typeLabel(alias.type)}</span>
|
||||
<span class="font-serif text-ink">
|
||||
{#if alias.firstName}{alias.firstName}{/if}
|
||||
{alias.lastName}
|
||||
</span>
|
||||
</span>
|
||||
{#if canWrite}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => confirmDelete(alias.id)}
|
||||
aria-label="{m.person_alias_btn_delete()} {alias.lastName}"
|
||||
class="ml-4 inline-flex min-h-[44px] min-w-[44px] items-center justify-center text-red-400 transition-colors hover:text-red-600"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
<div class="mt-4 border-t border-line pt-4">
|
||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.person_alias_add_heading()}
|
||||
</h3>
|
||||
|
||||
<form method="POST" action="?/addAlias" use:enhance>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-ink-2">{m.person_alias_label_type()}</span>
|
||||
<select
|
||||
name="type"
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="BIRTH">{m.person_alias_type_BIRTH()}</option>
|
||||
<option value="WIDOWED">{m.person_alias_type_WIDOWED()}</option>
|
||||
<option value="DIVORCED">{m.person_alias_type_DIVORCED()}</option>
|
||||
<option value="OTHER">{m.person_alias_type_OTHER()}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-ink-2">{m.person_alias_label_last_name()}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
required
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-ink-2">{m.person_alias_label_first_name()}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-sm bg-primary px-4 py-2 text-sm font-medium text-primary-fg transition-colors hover:bg-primary/80"
|
||||
>
|
||||
{m.person_alias_btn_add()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDeleteModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="mx-4 max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg">
|
||||
<h3 class="mb-2 font-serif text-lg text-ink">{m.person_alias_delete_title()}</h3>
|
||||
<p class="mb-6 text-sm text-ink-2">{m.person_alias_delete_body()}</p>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showDeleteModal = false;
|
||||
deleteTargetId = null;
|
||||
}}
|
||||
class="rounded-sm border border-line px-4 py-2 text-sm font-medium text-ink-2 transition-colors hover:bg-muted"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/removeAlias"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
showDeleteModal = false;
|
||||
deleteTargetId = null;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="aliasId" value={deleteTargetId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-sm bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{m.person_alias_btn_delete()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
||||
|
||||
const aliases = [
|
||||
{ id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 },
|
||||
{ id: 'a2', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 1 }
|
||||
];
|
||||
|
||||
describe('NameHistoryEditCard', () => {
|
||||
it('should render alias rows when aliases exist', async () => {
|
||||
render(NameHistoryEditCard, { aliases, canWrite: true });
|
||||
|
||||
await expect.element(page.getByText('de Gruyter')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no aliases', async () => {
|
||||
render(NameHistoryEditCard, { aliases: [], canWrite: true });
|
||||
|
||||
const emptyText = document.querySelector('.italic');
|
||||
expect(emptyText).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should show add form when canWrite is true', async () => {
|
||||
render(NameHistoryEditCard, { aliases: [], canWrite: true });
|
||||
|
||||
const form = document.querySelector('form[action="?/addAlias"]');
|
||||
expect(form).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should hide add form when canWrite is false', async () => {
|
||||
render(NameHistoryEditCard, { aliases: [], canWrite: false });
|
||||
|
||||
const form = document.querySelector('form[action="?/addAlias"]');
|
||||
expect(form).toBeNull();
|
||||
});
|
||||
|
||||
it('should hide delete buttons when canWrite is false', async () => {
|
||||
render(NameHistoryEditCard, { aliases, canWrite: false });
|
||||
|
||||
const deleteButtons = document.querySelectorAll('button[aria-label*="Entfernen"]');
|
||||
expect(deleteButtons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should show delete buttons when canWrite is true', async () => {
|
||||
render(NameHistoryEditCard, { aliases, canWrite: true });
|
||||
|
||||
const deleteButtons = document.querySelectorAll('button[aria-label*="Entfernen"]');
|
||||
expect(deleteButtons.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should include alias name in delete button aria-label', async () => {
|
||||
render(NameHistoryEditCard, { aliases: [aliases[0]], canWrite: true });
|
||||
|
||||
const btn = document.querySelector('button[aria-label*="de Gruyter"]');
|
||||
expect(btn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should show delete modal when delete button is clicked', async () => {
|
||||
render(NameHistoryEditCard, { aliases: [aliases[0]], canWrite: true });
|
||||
|
||||
const deleteBtn = document.querySelector('button[aria-label*="de Gruyter"]')!;
|
||||
deleteBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
await expect.element(page.getByText('Alias entfernen?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show alias error when provided', async () => {
|
||||
render(NameHistoryEditCard, {
|
||||
aliases: [],
|
||||
canWrite: true,
|
||||
aliasError: 'Something went wrong'
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have required attribute on lastName input', async () => {
|
||||
render(NameHistoryEditCard, { aliases: [], canWrite: true });
|
||||
|
||||
const input = document.querySelector('input[name="lastName"]') as HTMLInputElement;
|
||||
expect(input.required).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { discardHref }: { discardHref: string } = $props();
|
||||
let { discardHref, formId }: { discardHref: string; formId?: string } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Sticky full-bleed save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||
class="mt-6 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
||||
>
|
||||
<a href={discardHref} class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
|
||||
{m.person_discard_changes()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
formaction="?/update"
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('person detail load — happy path', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1', title: 'Brief' }] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||
@@ -45,6 +46,7 @@ describe('person detail load — happy path', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
||||
@@ -62,6 +64,7 @@ describe('person detail load — happy path', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||
@@ -81,6 +84,7 @@ describe('person detail load — error paths', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: false, status: 404 }, error: null })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
@@ -97,6 +101,7 @@ describe('person detail load — error paths', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: false, status: 403 }, error: null })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user