diff --git a/src/test/java/core_service/architecture/ArchitectureTest.java b/src/test/java/core_service/architecture/ArchitectureTest.java new file mode 100644 index 0000000..01ae4ab --- /dev/null +++ b/src/test/java/core_service/architecture/ArchitectureTest.java @@ -0,0 +1,102 @@ +package core_service.architecture; + +import core_service.shared.test.AbstractArchitectureTest; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * ArchUnit tests to enforce package boundaries in core-service. + * + * Notes on Core-specific structure (differs from auth-service): + * - JPA entities live in `..domain..` (not `..persistence..`). + * - Repository interfaces live in `..repo..`. + * - `steam_adapter` is an outbound port consumed by feature modules + * (e.g. `library`), so strict slice independence is relaxed for it. + */ +@AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest extends AbstractArchitectureTest { + + // =================================================================================== + // 1. MODULE INDEPENDENCE (Slices) + // =================================================================================== + + @ArchTest + static final ArchRule modules_should_not_depend_on_each_other = SlicesRuleDefinition.slices() + .matching("core_service.modules.(*)..") + .should().notDependOnEachOther() + .ignoreDependency( + JavaClass.Predicates.resideInAPackage("core_service.modules.library.."), + JavaClass.Predicates.resideInAPackage("core_service.modules.steam_adapter..")) + .because("Feature modules should be isolated; steam_adapter is an explicit shared port."); + + @ArchTest + static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices() + .matching("core_service.(*)..") + .should().beFreeOfCycles() + .because("Cyclic dependencies make the code unmaintainable and tightly coupled."); + + // =================================================================================== + // 2. LAYER DEPENDENCY DIRECTION + // =================================================================================== + + @ArchTest + static final ArchRule apis_should_not_depend_on_repos = noClasses() + .that().resideInAPackage("..api..") + .should().dependOnClassesThat().resideInAPackage("..repo..") + .because("Controllers must delegate to Services and not access repositories directly."); + + @ArchTest + static final ArchRule repos_should_not_depend_on_apis = noClasses() + .that().resideInAPackage("..repo..") + .should().dependOnClassesThat().resideInAPackage("..api..") + .because("Persistence layer must be independent of the API layer."); + + @ArchTest + static final ArchRule repos_should_be_interfaces_named_repository = classes() + .that().resideInAPackage("..repo..") + .should().haveSimpleNameEndingWith("Repository") + .because("Repository interfaces are the persistence boundary; naming makes them explicit."); + + // =================================================================================== + // 3. SHARED / CONFIGURATION RULES + // =================================================================================== + + @ArchTest + static final ArchRule shared_should_not_depend_on_modules = noClasses() + .that().resideInAPackage("core_service.shared..") + .should().dependOnClassesThat().resideInAPackage("core_service.modules..") + .because("Shared classes should be independent of specific modules."); + + @ArchTest + static final ArchRule modules_should_not_depend_on_global_configuration = noClasses() + .that().resideInAPackage("core_service.modules..") + .should().dependOnClassesThat().resideInAPackage("core_service.configuration..") + .because("Module logic should not depend on global Spring configuration classes."); + + // =================================================================================== + // 4. SPRING ANNOTATION CONVENTIONS + // =================================================================================== + + @ArchTest + static final ArchRule services_should_be_annotated = classes() + .that().resideInAPackage("..modules..service..") + .and().haveSimpleNameEndingWith("Service") + .should().beAnnotatedWith(org.springframework.stereotype.Service.class) + .because("Classes named *Service in modules should be registered as Spring @Service beans."); + + @ArchTest + static final ArchRule controllers_should_be_annotated = classes() + .that().resideInAPackage("..api..") + .and().haveSimpleNameEndingWith("Controller") + .should().beAnnotatedWith(org.springframework.web.bind.annotation.RestController.class) + .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class) + .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class) + .because("Classes in 'api' package named *Controller should be Spring REST controllers."); +} diff --git a/src/test/java/core_service/shared/test/AbstractArchitectureTest.java b/src/test/java/core_service/shared/test/AbstractArchitectureTest.java new file mode 100644 index 0000000..e93bf4e --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractArchitectureTest.java @@ -0,0 +1,10 @@ +package core_service.shared.test; + +import org.junit.jupiter.api.Tag; + +/** + * Base class for ArchUnit tests. + */ +@Tag("ArchitectureTest") +public abstract class AbstractArchitectureTest { +} diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java new file mode 100644 index 0000000..1fdc8a9 --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -0,0 +1,24 @@ +package core_service.shared.test; + +import org.junit.jupiter.api.Tag; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Base class for integration tests. + * Full Spring context with Testcontainers. + */ +@Tag("IntegrationTest") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("dev") +@Testcontainers +public abstract class AbstractIntegrationTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17-alpine"); +} diff --git a/src/test/java/core_service/shared/test/AbstractPersistenceTest.java b/src/test/java/core_service/shared/test/AbstractPersistenceTest.java new file mode 100644 index 0000000..eabf470 --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractPersistenceTest.java @@ -0,0 +1,24 @@ +package core_service.shared.test; + +import org.junit.jupiter.api.Tag; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Base class for persistence tests. + * Slices only the persistence layer with Testcontainers. + */ +@Tag("PersistenceTest") +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +public abstract class AbstractPersistenceTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17-alpine"); +} diff --git a/src/test/java/core_service/shared/test/AbstractUnitTest.java b/src/test/java/core_service/shared/test/AbstractUnitTest.java new file mode 100644 index 0000000..7c044c9 --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractUnitTest.java @@ -0,0 +1,14 @@ +package core_service.shared.test; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Base class for unit tests. + * Isolated from Spring context. + */ +@Tag("UnitTest") +@ExtendWith(MockitoExtension.class) +public abstract class AbstractUnitTest { +} diff --git a/src/test/java/core_service/shared/test/AbstractWebMvcTest.java b/src/test/java/core_service/shared/test/AbstractWebMvcTest.java new file mode 100644 index 0000000..99aaf84 --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractWebMvcTest.java @@ -0,0 +1,13 @@ +package core_service.shared.test; + +import org.junit.jupiter.api.Tag; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; + +/** + * Base class for WebMvc tests. + * Slices the web layer. + */ +@Tag("WebMvcTest") +@WebMvcTest +public abstract class AbstractWebMvcTest { +}