View Javadoc
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 }