diff --git a/auditlog/pom.xml b/auditlog/pom.xml
index e921f0e8..7df8c712 100644
--- a/auditlog/pom.xml
+++ b/auditlog/pom.xml
@@ -71,6 +71,12 @@
h2
runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
diff --git a/auditlog/src/main/resources/application.properties b/auditlog/src/main/resources/application.properties
deleted file mode 100644
index d4d640e5..00000000
--- a/auditlog/src/main/resources/application.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-spring.datasource.url=${SPRING_DATASOURCE_URL}
-spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
-spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
-
diff --git a/auditlog/src/main/resources/application.yml b/auditlog/src/main/resources/application.yml
index 24cd7618..97d6d399 100644
--- a/auditlog/src/main/resources/application.yml
+++ b/auditlog/src/main/resources/application.yml
@@ -1,17 +1,24 @@
server:
port: 8089
spring:
- application:
- name: auditlog-service
- rabbitmq:
- addresses: rabbitmq:5672
- username: shiftcontrol
- password: password
- jpa:
- properties:
- hibernate:
- type:
- json_format_mapper: jsonb
+ application:
+ name: auditlog-service
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ mvc:
+ servlet:
+ path: ${AUDITLOG_REST_PATH_PREFIX:/}
+ rabbitmq:
+ addresses: rabbitmq:5672
+ username: shiftcontrol
+ password: password
+ jpa:
+ properties:
+ hibernate:
+ type:
+ json_format_mapper: jsonb
keycloak:
auth-server-url: http://keycloak.127.0.0.1.nip.io/
auth-realm: dev
diff --git a/auditlog/src/test/java/at/shiftcontrol/auditlog/endpoint/LogEndpointPathPrefixTest.java b/auditlog/src/test/java/at/shiftcontrol/auditlog/endpoint/LogEndpointPathPrefixTest.java
new file mode 100644
index 00000000..cd7a6f36
--- /dev/null
+++ b/auditlog/src/test/java/at/shiftcontrol/auditlog/endpoint/LogEndpointPathPrefixTest.java
@@ -0,0 +1,71 @@
+package at.shiftcontrol.auditlog.endpoint;
+
+import java.time.Instant;
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.test.web.servlet.MockMvc;
+import org.junit.jupiter.api.Test;
+
+import at.shiftcontrol.auditlog.dto.LogEntryDto;
+import at.shiftcontrol.auditlog.dto.LogSearchDto;
+import at.shiftcontrol.auditlog.service.AuditLogService;
+import at.shiftcontrol.lib.dto.PaginationDto;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(
+ controllers = LogEndpoint.class,
+ properties = "spring.mvc.servlet.path=/auditlog",
+ excludeAutoConfiguration = OAuth2ResourceServerAutoConfiguration.class
+)
+@AutoConfigureMockMvc(addFilters = false)
+@Import(LogEndpointPathPrefixTest.TestConfig.class)
+class LogEndpointPathPrefixTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Value("${spring.mvc.servlet.path}")
+ private String servletPath;
+
+ @Test
+ void shouldExposeEndpointUnderConfiguredPrefix() throws Exception {
+ org.junit.jupiter.api.Assertions.assertEquals("/auditlog", servletPath);
+
+ mockMvc.perform(get("/auditlog/api/v1/log")
+ .servletPath("/auditlog")
+ .param("page", "0")
+ .param("size", "10")
+ .param("startTime", Instant.parse("2026-01-01T00:00:00Z").toString())
+ .param("endTime", Instant.parse("2026-01-02T00:00:00Z").toString()))
+ .andExpect(status().isOk());
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+ @Bean
+ @Primary
+ AuditLogService auditLogService() {
+ return new AuditLogService(null) {
+ @Override
+ public PaginationDto search(int page, int size, LogSearchDto searchDto) {
+ return PaginationDto.builder()
+ .page(page)
+ .pages(0)
+ .total(0)
+ .items(List.of())
+ .build();
+ }
+ };
+ }
+ }
+}
diff --git a/auditlog/src/test/java/at/shiftcontrol/auditlog/endpoint/LogEndpointRootServletPathTest.java b/auditlog/src/test/java/at/shiftcontrol/auditlog/endpoint/LogEndpointRootServletPathTest.java
new file mode 100644
index 00000000..bd3f9ecd
--- /dev/null
+++ b/auditlog/src/test/java/at/shiftcontrol/auditlog/endpoint/LogEndpointRootServletPathTest.java
@@ -0,0 +1,70 @@
+package at.shiftcontrol.auditlog.endpoint;
+
+import java.time.Instant;
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.test.web.servlet.MockMvc;
+import org.junit.jupiter.api.Test;
+
+import at.shiftcontrol.auditlog.dto.LogEntryDto;
+import at.shiftcontrol.auditlog.dto.LogSearchDto;
+import at.shiftcontrol.auditlog.service.AuditLogService;
+import at.shiftcontrol.lib.dto.PaginationDto;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(
+ controllers = LogEndpoint.class,
+ properties = "spring.mvc.servlet.path=/",
+ excludeAutoConfiguration = OAuth2ResourceServerAutoConfiguration.class
+)
+@AutoConfigureMockMvc(addFilters = false)
+@Import(LogEndpointRootServletPathTest.TestConfig.class)
+class LogEndpointRootServletPathTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Value("${spring.mvc.servlet.path}")
+ private String servletPath;
+
+ @Test
+ void shouldExposeEndpointWithoutAdditionalPrefix() throws Exception {
+ org.junit.jupiter.api.Assertions.assertEquals("/", servletPath);
+
+ mockMvc.perform(get("/api/v1/log")
+ .param("page", "0")
+ .param("size", "10")
+ .param("startTime", Instant.parse("2026-01-01T00:00:00Z").toString())
+ .param("endTime", Instant.parse("2026-01-02T00:00:00Z").toString()))
+ .andExpect(status().isOk());
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+ @Bean
+ @Primary
+ AuditLogService auditLogService() {
+ return new AuditLogService(null) {
+ @Override
+ public PaginationDto search(int page, int size, LogSearchDto searchDto) {
+ return PaginationDto.builder()
+ .page(page)
+ .pages(0)
+ .total(0)
+ .items(List.of())
+ .build();
+ }
+ };
+ }
+ }
+}
diff --git a/shiftservice/README.md b/shiftservice/README.md
new file mode 100644
index 00000000..1f0fbd80
--- /dev/null
+++ b/shiftservice/README.md
@@ -0,0 +1,65 @@
+# Shiftservice
+
+## Runtime configuration
+
+`shiftservice` expects these environment variables in normal runtime:
+
+- `SPRING_DATASOURCE_URL`
+- `SPRING_DATASOURCE_USERNAME`
+- `SPRING_DATASOURCE_PASSWORD`
+
+Optional service-specific variables:
+
+- `SHIFTSERVICE_REST_PATH_PREFIX`
+ - Defaults to `/`, which keeps endpoints at `/api/v1/...` with no extra prefix.
+ - Example: set `/shiftservice` to expose endpoints as `/shiftservice/api/v1/...`.
+- `SPRING_LIQUIBASE_CONTEXTS`
+ - Defaults to `dev`.
+ - Controls which Liquibase context-tagged change sets are applied.
+
+## Database and Liquibase
+
+Main configuration lives in [application.yml](./src/main/resources/application.yml).
+
+The service runs Hibernate with `ddl-auto=validate`, so schema creation and migration are handled by Liquibase, not Hibernate.
+
+Liquibase is enabled by default and uses:
+
+- changelog: `classpath:db/changelog-master.xml`
+- default context: `dev`
+
+## Environments
+
+### Development
+
+Use:
+
+- `SPRING_LIQUIBASE_CONTEXTS=dev` or leave it unset
+
+Effect:
+
+- schema migrations run
+- development seed data in `db/data/*` runs because those change sets are tagged with `context="dev"`
+
+### Production
+
+Use:
+
+- `SPRING_LIQUIBASE_CONTEXTS=prod`
+
+Effect:
+
+- schema migrations still run
+- `dev` seed data is skipped
+
+This matters because the seed data files under `db/data/` are marked with the `dev` Liquibase context.
+
+## OpenAPI profile
+
+The `openapi` profile uses [application-openapi.yml](./src/main/resources/application-openapi.yml):
+
+- in-memory H2 datasource
+- Liquibase disabled
+- Hibernate `ddl-auto=none`
+
+That profile is intended only for OpenAPI generation, not normal app runtime.
diff --git a/shiftservice/pom.xml b/shiftservice/pom.xml
index d2faa226..e52e7120 100644
--- a/shiftservice/pom.xml
+++ b/shiftservice/pom.xml
@@ -90,6 +90,11 @@
spring-security-test
test
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
org.apache.poi
poi-ooxml
@@ -122,6 +127,12 @@
+
+ unit-tests
+
+ true
+
+
openapi
diff --git a/shiftservice/src/main/resources/application.properties b/shiftservice/src/main/resources/application.properties
deleted file mode 100644
index 64cbaa11..00000000
--- a/shiftservice/src/main/resources/application.properties
+++ /dev/null
@@ -1,12 +0,0 @@
-spring.datasource.url=${SPRING_DATASOURCE_URL}
-spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
-spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
-# let liquibase handle table creation
-spring.jpa.hibernate.ddl-auto=validate
-spring.jpa.database=postgresql
-spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
-spring.liquibase.change-log=classpath:db/changelog-master.xml
-spring.liquibase.enabled=true
-# set context to dev to insert test-data, else use prod
-spring.liquibase.contexts=dev
-
diff --git a/shiftservice/src/main/resources/application.yml b/shiftservice/src/main/resources/application.yml
index 4aaa4b33..d5f72ce6 100644
--- a/shiftservice/src/main/resources/application.yml
+++ b/shiftservice/src/main/resources/application.yml
@@ -1,15 +1,31 @@
spring:
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
rabbitmq:
addresses: rabbitmq:5672
username: shiftcontrol
password: password
jackson:
default-property-inclusion: non_null
+ mvc:
+ servlet:
+ path: ${SHIFTSERVICE_REST_PATH_PREFIX:/}
+ jpa:
+ database: postgresql
+ database-platform: org.hibernate.dialect.PostgreSQLDialect
+ hibernate:
+ ddl-auto: validate
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://keycloak.127.0.0.1.nip.io/realms/dev
+ liquibase:
+ change-log: classpath:db/changelog-master.xml
+ enabled: true
+ contexts: ${SPRING_LIQUIBASE_CONTEXTS:dev}
keycloak:
auth-server-url: http://keycloak.127.0.0.1.nip.io/
auth-realm: dev
diff --git a/shiftservice/src/test/java/at/shiftcontrol/shiftservice/endpoint/UserProfileEndpointRootServletPathTest.java b/shiftservice/src/test/java/at/shiftcontrol/shiftservice/endpoint/UserProfileEndpointRootServletPathTest.java
new file mode 100644
index 00000000..b2dfdf38
--- /dev/null
+++ b/shiftservice/src/test/java/at/shiftcontrol/shiftservice/endpoint/UserProfileEndpointRootServletPathTest.java
@@ -0,0 +1,114 @@
+package at.shiftcontrol.shiftservice.endpoint;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.test.web.servlet.MockMvc;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import at.shiftcontrol.shiftservice.auth.ApplicationUserProvider;
+import at.shiftcontrol.shiftservice.auth.user.ShiftControlUser;
+import at.shiftcontrol.shiftservice.dto.userprofile.NotificationSettingsDto;
+import at.shiftcontrol.shiftservice.dto.userprofile.UserProfileDto;
+import at.shiftcontrol.shiftservice.service.userprofile.NotificationService;
+import at.shiftcontrol.shiftservice.service.userprofile.UserProfileService;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(
+ controllers = UserProfileEndpoint.class,
+ properties = "spring.mvc.servlet.path=/",
+ excludeAutoConfiguration = OAuth2ResourceServerAutoConfiguration.class,
+ excludeFilters = @ComponentScan.Filter(
+ type = FilterType.REGEX,
+ pattern = "at\\.shiftcontrol\\.shiftservice\\.config\\.TraceIdFilter"
+ )
+)
+@AutoConfigureMockMvc(addFilters = false)
+@Import(UserProfileEndpointRootServletPathTest.TestConfig.class)
+class UserProfileEndpointRootServletPathTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Value("${spring.mvc.servlet.path}")
+ private String servletPath;
+
+ @Test
+ void shouldExposeEndpointWithoutAdditionalPrefix() throws Exception {
+ Assertions.assertEquals("/", servletPath);
+
+ mockMvc.perform(get("/api/v1/me/profile"))
+ .andExpect(status().isOk());
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+ @Bean
+ @Primary
+ ApplicationUserProvider applicationUserProvider() {
+ return new ApplicationUserProvider() {
+ @Override
+ public ShiftControlUser getCurrentUser() {
+ return new ShiftControlUser(List.of(), "test-user", "123456789") {
+ @Override
+ public boolean isVolunteerInPlan(long shiftPlanId) {
+ return false;
+ }
+
+ @Override
+ public boolean isPlannerInPlan(long shiftPlanId) {
+ return false;
+ }
+
+ @Override
+ public boolean isLockedInPlan(long shiftPlanId) {
+ return false;
+ }
+ };
+ }
+ };
+ }
+
+ @Bean
+ @Primary
+ UserProfileService userProfileService() {
+ return userId -> UserProfileDto.builder()
+ .account(null)
+ .notifications(List.of())
+ .assignedRoles(List.of())
+ .planningPlans(List.of())
+ .volunteeringPlans(List.of())
+ .planningEvents(List.of())
+ .volunteeringEvents(List.of())
+ .build();
+ }
+
+ @Bean
+ @Primary
+ NotificationService notificationService() {
+ return new NotificationService() {
+ @Override
+ public java.util.Collection getNotificationsForUser(String userId) {
+ return List.of();
+ }
+
+ @Override
+ public NotificationSettingsDto updateNotificationSetting(String userId, NotificationSettingsDto settingsDto) {
+ return settingsDto;
+ }
+ };
+ }
+ }
+}
diff --git a/trustservice/pom.xml b/trustservice/pom.xml
index 6850d496..0e6a8f07 100644
--- a/trustservice/pom.xml
+++ b/trustservice/pom.xml
@@ -84,4 +84,13 @@
+
+
+ unit-tests
+
+ true
+
+
+
+