refactor: adopt feature package boundaries and JPA
This commit is contained in:
@@ -5,6 +5,8 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.lab.labtimesheet;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
abstract class PlatformDatabaseTestSupport {
|
||||
|
||||
@Autowired
|
||||
protected JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void resetPlatformData() {
|
||||
jdbc.execute("TRUNCATE smtp_configurations, user_action_tokens, intern_profiles, app_users RESTART IDENTITY CASCADE");
|
||||
jdbc.update("insert into system_state (singleton_id) values (1)");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.lab.labtimesheet;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
|
||||
public class TestLabtimesheetApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.lab.labtimesheet.LabtimesheetApplication;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LayerStructureTest {
|
||||
private static final Path BASE_PACKAGE = Path.of("src/main/java/com/lab/labtimesheet");
|
||||
private static final Set<String> APPROVED_ROOT_PACKAGES = Set.of("config", "feature");
|
||||
private static final Set<String> APPROVED_FEATURES = Set.of(
|
||||
"account", "integration", "project", "task", "attendance", "notification", "reporting");
|
||||
private static final Set<String> APPROVED_FEATURE_PACKAGES = Set.of(
|
||||
"controller", "exception", "model", "model/dto", "model/entity", "repository", "service");
|
||||
private static final Pattern INTERNAL_IMPORT = Pattern.compile(
|
||||
"import com\\.lab\\.labtimesheet\\.feature\\.([^.]+)\\.(?:repository|model\\.entity)\\.");
|
||||
|
||||
@Test
|
||||
void applicationUsesOnlyApprovedPackageByFeatureStructure() throws IOException {
|
||||
assertThat(LabtimesheetApplication.class.getPackageName()).isEqualTo("com.lab.labtimesheet");
|
||||
|
||||
try (var entries = Files.list(BASE_PACKAGE)) {
|
||||
Set<String> directories = entries
|
||||
.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertThat(directories).containsExactlyInAnyOrderElementsOf(APPROVED_ROOT_PACKAGES);
|
||||
}
|
||||
|
||||
Path featurePackage = BASE_PACKAGE.resolve("feature");
|
||||
try (var entries = Files.list(featurePackage)) {
|
||||
Set<String> features = entries
|
||||
.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertThat(features).isNotEmpty().isSubsetOf(APPROVED_FEATURES);
|
||||
}
|
||||
|
||||
try (var entries = Files.walk(featurePackage)) {
|
||||
List<String> featurePackages = entries
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getNameCount() > featurePackage.getNameCount() + 1)
|
||||
.map(path -> path.subpath(featurePackage.getNameCount() + 1, path.getNameCount()).toString())
|
||||
.toList();
|
||||
|
||||
assertThat(featurePackages).allMatch(APPROVED_FEATURE_PACKAGES::contains);
|
||||
}
|
||||
|
||||
try (var entries = Files.walk(featurePackage)) {
|
||||
List<String> crossFeaturePersistenceImports = entries
|
||||
.filter(path -> path.toString().endsWith(".java"))
|
||||
.flatMap(path -> persistenceImportsFromAnotherFeature(featurePackage, path).stream())
|
||||
.toList();
|
||||
|
||||
assertThat(crossFeaturePersistenceImports).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> persistenceImportsFromAnotherFeature(Path featurePackage, Path source) {
|
||||
String owningFeature = featurePackage.relativize(source).getName(0).toString();
|
||||
try {
|
||||
return Files.readAllLines(source).stream()
|
||||
.filter(line -> {
|
||||
var matcher = INTERNAL_IMPORT.matcher(line);
|
||||
return matcher.find() && !matcher.group(1).equals(owningFeature);
|
||||
})
|
||||
.map(line -> source + ": " + line.trim())
|
||||
.toList();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Cannot inspect " + source, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-16
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -11,7 +11,6 @@ import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
@@ -21,26 +20,12 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
@ActiveProfiles("test")
|
||||
class PlatformFoundationTest {
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private Clock clock;
|
||||
|
||||
@Test
|
||||
void applicationExposesRequiredModulePackages() throws ClassNotFoundException {
|
||||
assertThat(applicationContext).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.accounts.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.configuration.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.projects.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.attendance.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.notifications.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.reporting.ModuleBoundary")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void flywayCreatesApprovedPostgresCatalog() {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -12,7 +12,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
class TestcontainersConfiguration {
|
||||
public class TestcontainersConfiguration {
|
||||
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
+32
-13
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -10,28 +10,42 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import com.lab.labtimesheet.accounts.BootstrapService;
|
||||
import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome;
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class BootstrapIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrapService;
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private SystemStateRepository systemStates;
|
||||
|
||||
@Test
|
||||
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
||||
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
||||
@@ -46,7 +60,7 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception {
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<BootstrapOutcome>> futures = new ArrayList<>();
|
||||
List<Future<BootstrapService.BootstrapOutcome>> futures = new ArrayList<>();
|
||||
|
||||
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
@@ -63,15 +77,20 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
}
|
||||
|
||||
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
|
||||
BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject(
|
||||
"select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'",
|
||||
Integer.class)).isEqualTo(1);
|
||||
BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1);
|
||||
var createdUser = users.findAll().getFirst();
|
||||
var identityByEmail = accountService.requireIdentityByEmail(" " + createdUser.getEmail().toUpperCase() + " ");
|
||||
assertThat(identityByEmail.email()).isEqualTo(createdUser.getEmail());
|
||||
assertThat(identityByEmail.displayName()).isEqualTo("First Admin");
|
||||
assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN);
|
||||
assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail);
|
||||
assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse();
|
||||
assertThat(bootstrapService.bootstrap(
|
||||
"another@example.com", "Another", "correct horse battery staple"))
|
||||
.isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class))
|
||||
.isTrue();
|
||||
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
|
||||
}
|
||||
}
|
||||
+26
-21
@@ -1,14 +1,18 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.lab.labtimesheet.accounts.BootstrapService;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft;
|
||||
import com.lab.labtimesheet.configuration.SmtpProbe;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -21,46 +25,47 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class SmtpIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
class SmtpIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrapService;
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtpService;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe smtpProbe;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationRepository configurations;
|
||||
|
||||
@Test
|
||||
void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() {
|
||||
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = jdbc.queryForObject("select id from app_users", Long.class);
|
||||
long adminId = accountService.requireActiveAdminId("admin@example.com");
|
||||
long draftId = smtpService.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab"));
|
||||
|
||||
byte[] ciphertext = jdbc.queryForObject(
|
||||
"select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId);
|
||||
var savedDraft = configurations.findById(draftId).orElseThrow();
|
||||
byte[] ciphertext = savedDraft.getPasswordCiphertext();
|
||||
assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password");
|
||||
assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?",
|
||||
Integer.class, draftId)).isEqualTo(12);
|
||||
assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?",
|
||||
Integer.class, draftId)).isEqualTo(1);
|
||||
assertThat(savedDraft.getPasswordNonce()).hasSize(12);
|
||||
assertThat(savedDraft.getSecretKeyVersion()).isEqualTo(1);
|
||||
smtpProbe.fail = true;
|
||||
assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId))
|
||||
.isEqualTo("DRAFT");
|
||||
assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class,
|
||||
draftId)).isTrue();
|
||||
assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.DRAFT);
|
||||
assertThat(configurations.findById(draftId).orElseThrow().getTestedAt()).isNull();
|
||||
assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class);
|
||||
|
||||
smtpProbe.fail = false;
|
||||
smtpService.testDraft(draftId, adminId, "admin@example.com");
|
||||
smtpService.activate(draftId, adminId);
|
||||
|
||||
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId))
|
||||
.isEqualTo("ACTIVE");
|
||||
assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
@@ -76,7 +81,7 @@ class SmtpIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject,
|
||||
public void send(SmtpConnection connection, String recipient, String subject,
|
||||
String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
Reference in New Issue
Block a user