test(geschichte): add Testcontainers integration test and fix V58 author FK

The end-to-end test creates a DRAFT, verifies it is hidden from a READ_ALL
reader (list and getById), publishes it, verifies the reader sees it, then
deletes it and confirms the join rows go with it but the linked Person
remains. Also corrects the V58 author FK to reference the actual users
table (not app_users).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:33:52 +02:00
parent 9fc96a15cf
commit e5024fc804
2 changed files with 128 additions and 1 deletions

View File

@@ -6,7 +6,7 @@ CREATE TABLE geschichten (
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
body TEXT, body TEXT,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
author_id UUID REFERENCES app_users (id) ON DELETE SET NULL, author_id UUID REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL,
published_at TIMESTAMP published_at TIMESTAMP

View File

@@ -0,0 +1,127 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class GeschichteServiceIntegrationTest {
@MockitoBean
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
AppUser writer;
AppUser reader;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("writer-int@test")
.password("hash")
.build());
reader = appUserRepository.save(AppUser.builder()
.email("reader-int@test")
.password("hash")
.build());
}
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
@Test
void create_then_publish_then_read_then_delete_full_lifecycle() {
// Create as writer
authenticateAs(writer, Permission.BLOG_WRITE);
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("Raddatz").build());
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Erinnerung an Opa Franz");
dto.setBody("<p>Ich erinnere mich, wie er <strong>jeden Sonntag</strong> sang.</p>"
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
// Publish as writer
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).hasSize(1);
assertThat(geschichteService.list(null, franz.getId(), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
geschichteService.delete(draftId);
assertThat(geschichteRepository.findById(draftId)).isEmpty();
// The Person itself is untouched (cascade only flows from Geschichte to join table)
assertThat(personRepository.findById(franz.getId())).isPresent();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
}