refactor: adopt feature package boundaries and JPA

This commit is contained in:
sechmachine
2026-08-15 00:14:21 +07:00
parent bc70db1d0d
commit 3fdfbb2bf2
54 changed files with 1137 additions and 466 deletions
@@ -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,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);
@@ -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
@@ -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();
}
}
@@ -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");