feat(geschichten): blog-like family memory stories (closes #381) #382

Merged
marcel merged 30 commits from feat/issue-381-geschichten into main 2026-05-04 15:02:45 +02:00
2 changed files with 128 additions and 1 deletions
Showing only changes of commit e5024fc804 - Show all commits

View File

@@ -6,7 +6,7 @@ CREATE TABLE geschichten (
title VARCHAR(255) NOT NULL,
body TEXT,
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,
updated_at TIMESTAMP NOT NULL,
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));
}
}