1 package fr.tiogars.inception.domains.domaintemplate; 2 3 import static org.junit.jupiter.api.Assertions.assertEquals; 4 import static org.junit.jupiter.api.Assertions.assertNotEquals; 5 import static org.junit.jupiter.api.Assertions.assertThrows; 6 import static org.junit.jupiter.api.Assertions.assertTrue; 7 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; 8 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 9 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 10 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 11 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 12 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 13 14 import java.io.IOException; 15 import java.nio.charset.StandardCharsets; 16 import java.sql.Connection; 17 import java.sql.DriverManager; 18 import java.sql.SQLException; 19 import java.sql.Statement; 20 import java.time.Instant; 21 import java.util.List; 22 import java.util.Map; 23 24 import org.apache.pdfbox.Loader; 25 import org.apache.pdfbox.pdmodel.PDDocument; 26 import org.apache.pdfbox.text.PDFTextStripper; 27 import org.junit.jupiter.api.AfterEach; 28 import org.junit.jupiter.api.BeforeEach; 29 import org.junit.jupiter.api.Tag; 30 import org.junit.jupiter.api.Test; 31 import org.springframework.beans.factory.annotation.Autowired; 32 import org.springframework.boot.test.context.SpringBootTest; 33 import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 34 import org.springframework.core.io.ClassPathResource; 35 import org.springframework.dao.DataIntegrityViolationException; 36 import org.springframework.http.MediaType; 37 import org.springframework.jdbc.core.JdbcTemplate; 38 import org.springframework.orm.ObjectOptimisticLockingFailureException; 39 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 40 import org.springframework.security.core.context.SecurityContextHolder; 41 import org.springframework.test.context.DynamicPropertyRegistry; 42 import org.springframework.test.context.DynamicPropertySource; 43 import org.springframework.test.web.servlet.MockMvc; 44 import org.testcontainers.junit.jupiter.Container; 45 import org.testcontainers.junit.jupiter.Testcontainers; 46 import org.testcontainers.postgresql.PostgreSQLContainer; 47 48 import com.jayway.jsonpath.JsonPath; 49 50 import fr.tiogars.inception.domains.domaintemplate.entities.DomainTemplateEntity; 51 import fr.tiogars.inception.domains.domaintemplate.repositories.DomainTemplateRepository; 52 53 @SpringBootTest 54 @AutoConfigureMockMvc(addFilters = false) 55 @Testcontainers 56 @Tag("Layer1") 57 class DomainTemplateAuditingL1Test { 58 59 @Container 60 private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine") 61 .withInitScript("db/legacy-domain-templates.sql"); 62 63 @Autowired 64 private MockMvc mvc; 65 66 @Autowired 67 private JdbcTemplate jdbc; 68 69 @Autowired 70 private DomainTemplateRepository repository; 71 72 @DynamicPropertySource 73 static void databaseProperties(DynamicPropertyRegistry registry) throws SQLException, IOException { 74 try (Connection connection = DriverManager.getConnection( 75 POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); 76 Statement statement = connection.createStatement()) { 77 statement.execute(migrationSql()); 78 statement.execute(migrationSql()); 79 } 80 registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); 81 registry.add("spring.datasource.username", POSTGRES::getUsername); 82 registry.add("spring.datasource.password", POSTGRES::getPassword); 83 registry.add("spring.jpa.hibernate.ddl-auto", () -> "update"); 84 } 85 86 @BeforeEach 87 void authenticate() { 88 authenticateAs("creator"); 89 } 90 91 @AfterEach 92 void cleanUp() { 93 jdbc.update("DELETE FROM domain_templates WHERE code <> 'LEGACY'"); 94 SecurityContextHolder.clearContext(); 95 } 96 97 @Test 98 void migration_preservesLegacyRowsAndAllowsFreshDatabaseSetup() throws SQLException, IOException { 99 Map<String, Object> legacy = jdbc.queryForMap("SELECT * FROM domain_templates WHERE code = 'LEGACY'"); 100 assertEquals("unknown", legacy.get("created_by")); 101 assertEquals("unknown", legacy.get("last_modified_by")); 102 assertEquals(Instant.parse("2026-08-01T00:00:00Z"), 103 jdbc.queryForObject("SELECT created_date FROM domain_templates WHERE code = 'LEGACY'", 104 java.sql.Timestamp.class).toInstant()); 105 106 try (Connection connection = DriverManager.getConnection( 107 POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); 108 Statement statement = connection.createStatement()) { 109 statement.execute("CREATE SCHEMA fresh_database"); 110 statement.execute("SET search_path TO fresh_database"); 111 statement.execute(migrationSql()); 112 try (var result = statement.executeQuery( 113 "SELECT count(*) FROM pg_indexes WHERE schemaname = 'fresh_database' " 114 + "AND indexname = 'uk_domain_templates_active_code'")) { 115 assertTrue(result.next()); 116 assertEquals(1, result.getInt(1)); 117 } 118 statement.execute("DROP SCHEMA fresh_database CASCADE"); 119 } 120 } 121 122 @Test 123 void lifecycle_auditsActorsRetainsDeletedRowsAndReusesCodeWithNewId() throws Exception { 124 long originalId = create("CUSTOMER"); 125 DomainTemplateEntity original = repository.findById(originalId).orElseThrow(); 126 assertEquals("creator", original.getCreatedBy()); 127 assertEquals("creator", original.getLastModifiedBy()); 128 Instant createdDate = original.getCreatedDate(); 129 assertEquals(createdDate, original.getLastModifiedDate()); 130 assertEquals(0, createdDate.getNano() % 1000); 131 132 authenticateAs("editor"); 133 String response = mvc.perform(put("/api/v1/domaintemplate/{id}", originalId) 134 .contentType(MediaType.APPLICATION_JSON) 135 .content("{\"label\":\"Updated\",\"description\":\"Edited\"}")) 136 .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); 137 DomainTemplateEntity updated = repository.findById(originalId).orElseThrow(); 138 assertEquals("creator", updated.getCreatedBy()); 139 assertEquals("editor", updated.getLastModifiedBy()); 140 assertEquals(createdDate, updated.getCreatedDate()); 141 assertTrue(updated.getLastModifiedDate().isAfter(createdDate)); 142 assertEquals(updated.getLastModifiedDate(), Instant.parse(JsonPath.read(response, "$.lastModifiedDate"))); 143 144 authenticateAs("deleter"); 145 mvc.perform(delete("/api/v1/domaintemplate/{id}", originalId)).andExpect(status().isNoContent()); 146 Map<String, Object> archived = jdbc.queryForMap("SELECT * FROM domain_templates WHERE id = ?", originalId); 147 assertEquals("CUSTOMER", archived.get("code")); 148 assertEquals("creator", archived.get("created_by")); 149 assertEquals("deleter", archived.get("deleted_by")); 150 assertEquals("deleter", archived.get("last_modified_by")); 151 assertTrue(archived.get("deleted_date") != null); 152 assertTrue(repository.findById(originalId).isEmpty()); 153 assertTrue(repository.findByCode("CUSTOMER").isEmpty()); 154 assertTrue(!repository.existsById(originalId)); 155 mvc.perform(get("/api/v1/domaintemplate/{id}", originalId)).andExpect(status().isNotFound()); 156 mvc.perform(put("/api/v1/domaintemplate/{id}", originalId) 157 .contentType(MediaType.APPLICATION_JSON).content("{\"label\":\"Resurrect\"}")) 158 .andExpect(status().isNotFound()); 159 mvc.perform(delete("/api/v1/domaintemplate/{id}", originalId)).andExpect(status().isNotFound()); 160 String exported = mvc.perform(get("/api/v1/domaintemplate/export.json")) 161 .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); 162 List<String> exportedCodes = JsonPath.read(exported, "$[*].code"); 163 assertTrue(!exportedCodes.contains("CUSTOMER")); 164 mvc.perform(get("/api/v1/domaintemplate/export.csv")) 165 .andExpect(result -> assertTrue(!result.getResponse().getContentAsString().contains("CUSTOMER"))); 166 167 authenticateAs("replacementCreator"); 168 long replacementId = create("CUSTOMER"); 169 assertNotEquals(originalId, replacementId); 170 assertEquals(2, jdbc.queryForObject( 171 "SELECT count(*) FROM domain_templates WHERE code = 'CUSTOMER'", Integer.class)); 172 mvc.perform(get("/api/v1/domaintemplate")) 173 .andExpect(jsonPath("$[0].id").value(replacementId)) 174 .andExpect(jsonPath("$.length()").value(2)); 175 mvc.perform(post("/api/v1/domaintemplate").contentType(MediaType.APPLICATION_JSON) 176 .content("{\"code\":\"CUSTOMER\",\"label\":\"Duplicate\"}")).andExpect(status().isConflict()); 177 assertThrows(DataIntegrityViolationException.class, 178 () -> repository.saveAndFlush(new DomainTemplateEntity("CUSTOMER", "Duplicate", null))); 179 } 180 181 @Test 182 void pdfPrinting_excludesArchivedRowsAndOnlyPrintsSelectedActiveRecords() throws Exception { 183 long archivedId = create("ARCHIVED_PDF"); 184 long selectedId = create("SELECTED_PDF"); 185 create("UNSELECTED_PDF"); 186 mvc.perform(delete("/api/v1/domaintemplate/{id}", archivedId)).andExpect(status().isNoContent()); 187 188 mvc.perform(get("/api/v1/domaintemplate/{id}/print.pdf", archivedId)).andExpect(status().isNotFound()); 189 mvc.perform(post("/api/v1/domaintemplate/print-selected.pdf") 190 .contentType(MediaType.APPLICATION_JSON) 191 .content("{\"ids\":[" + selectedId + "," + archivedId + "]}")).andExpect(status().isNotFound()); 192 193 byte[] selectedPdf = mvc.perform(post("/api/v1/domaintemplate/print-selected.pdf") 194 .contentType(MediaType.APPLICATION_JSON).content("{\"ids\":[" + selectedId + "]}")) 195 .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray(); 196 try (PDDocument pdf = Loader.loadPDF(selectedPdf)) { 197 String text = new PDFTextStripper().getText(pdf); 198 assertTrue(text.contains("SELECTED_PDF")); 199 assertTrue(!text.contains("UNSELECTED_PDF")); 200 assertTrue(!text.contains("ARCHIVED_PDF")); 201 assertEquals(1, pdf.getNumberOfPages()); 202 } 203 204 byte[] allPdf = mvc.perform(get("/api/v1/domaintemplate/print.pdf")) 205 .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray(); 206 try (PDDocument pdf = Loader.loadPDF(allPdf)) { 207 String text = new PDFTextStripper().getText(pdf); 208 assertTrue(text.contains("SELECTED_PDF")); 209 assertTrue(text.contains("UNSELECTED_PDF")); 210 assertTrue(!text.contains("ARCHIVED_PDF")); 211 } 212 } 213 214 @Test 215 void staleUpdate_cannotOverwriteAnArchivedRow() throws Exception { 216 long id = create("STALE"); 217 DomainTemplateEntity stale = repository.findById(id).orElseThrow(); 218 mvc.perform(delete("/api/v1/domaintemplate/{id}", id)).andExpect(status().isNoContent()); 219 stale.update("Stale update", null); 220 221 assertThrows(ObjectOptimisticLockingFailureException.class, () -> repository.saveAndFlush(stale)); 222 assertEquals("Original", jdbc.queryForObject( 223 "SELECT label FROM domain_templates WHERE id = ?", String.class, id)); 224 } 225 226 @Test 227 void importsAndSeed_createNewRowsForArchivedCodesAndAuditUpserts() throws Exception { 228 long oldId = create("IMPORT"); 229 mvc.perform(delete("/api/v1/domaintemplate/{id}", oldId)).andExpect(status().isNoContent()); 230 authenticateAs("importer"); 231 mvc.perform(post("/api/v1/domaintemplate/import.json").contentType(MediaType.APPLICATION_JSON) 232 .content("[{\"code\":\"IMPORT\",\"label\":\"Imported\",\"createdBy\":\"spoofed\"}]")) 233 .andExpect(status().isCreated()); 234 DomainTemplateEntity imported = repository.findByCode("IMPORT").orElseThrow(); 235 assertNotEquals(oldId, imported.getId()); 236 assertEquals("importer", imported.getCreatedBy()); 237 238 authenticateAs("seeder"); 239 mvc.perform(post("/api/v1/domaintemplate/seed").contentType(MediaType.APPLICATION_JSON) 240 .content("[{\"code\":\"IMPORT\",\"label\":\"Seeded\"}]")).andExpect(status().isCreated()); 241 assertEquals("seeder", repository.findByCode("IMPORT").orElseThrow().getLastModifiedBy()); 242 mvc.perform(delete("/api/v1/domaintemplate/{id}", imported.getId())).andExpect(status().isNoContent()); 243 244 authenticateAs("csvImporter"); 245 mvc.perform(post("/api/v1/domaintemplate/import.csv").contentType("text/csv") 246 .content("id,code,label,description,createdDate,lastModifiedDate\n" 247 + "999,IMPORT,CSV import,,,\n")).andExpect(status().isCreated()); 248 DomainTemplateEntity csvImported = repository.findByCode("IMPORT").orElseThrow(); 249 assertNotEquals(imported.getId(), csvImported.getId()); 250 assertEquals("csvImporter", csvImported.getCreatedBy()); 251 assertEquals(3, jdbc.queryForObject( 252 "SELECT count(*) FROM domain_templates WHERE code = 'IMPORT'", Integer.class)); 253 } 254 255 private long create(String code) throws Exception { 256 String response = mvc.perform(post("/api/v1/domaintemplate").contentType(MediaType.APPLICATION_JSON) 257 .content("{\"code\":\"" + code + "\",\"label\":\"Original\"}")) 258 .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString(); 259 Number id = JsonPath.read(response, "$.id"); 260 return id.longValue(); 261 } 262 263 private static void authenticateAs(String username) { 264 SecurityContextHolder.getContext().setAuthentication( 265 UsernamePasswordAuthenticationToken.authenticated(username, null, List.of())); 266 } 267 268 private static String migrationSql() throws IOException { 269 return new ClassPathResource("db/manual/domain-template-auditing-soft-delete.sql") 270 .getContentAsString(StandardCharsets.UTF_8); 271 } 272 }