From de9242c4f7e8f232f611968dce2cb45b21c0fa1a Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Wed, 17 Jun 2026 12:04:53 +0100
Subject: [PATCH] Add JUnit tests for saas module coverage (#6699)
# Description of Changes
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.../ai/controller/AiCreateControllerTest.java | 1035 +++++++++++++
.../AiCreateInternalControllerTest.java | 457 ++++++
.../ai/controller/AiProxyControllerTest.java | 802 ++++++++++
.../ai/service/AiCreateProxyServiceTest.java | 676 ++++++++
.../service/AiCreateSessionServiceTest.java | 783 ++++++++++
.../saas/ai/service/AiProxyServiceTest.java | 678 +++++++++
.../controller/SaasTeamControllerTest.java | 1355 +++++++++++++++++
.../UserRoleWebhookControllerTest.java | 556 +++++++
.../interceptor/CreditErrorAdviceTest.java | 725 +++++++++
.../interceptor/CreditSuccessAdviceTest.java | 679 +++++++++
.../UnifiedCreditInterceptorTest.java | 806 ++++++++++
.../AnonymousUserCleanupServiceTest.java | 351 +++++
.../service/CreditResetSchedulerTest.java | 219 +++
.../saas/service/CreditServiceTest.java | 1291 ++++++++++++++++
.../service/ErrorTrackingServiceTest.java | 586 +++++++
.../saas/service/RateLimitServiceTest.java | 229 +++
.../service/SaasTeamExtensionServiceTest.java | 585 +++++++
.../service/SaasUserAccountServiceTest.java | 475 ++++++
.../saas/service/SupabaseUserServiceTest.java | 205 +++
.../saas/service/TeamCreditServiceTest.java | 612 ++++++++
.../saas/service/UserRoleServiceTest.java | 378 +++++
.../saas/util/CreditHeaderUtilsTest.java | 348 +++++
22 files changed, 13831 insertions(+)
create mode 100644 app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateInternalControllerTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateProxyServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateSessionServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/ai/service/AiProxyServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/controller/UserRoleWebhookControllerTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/AnonymousUserCleanupServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/SaasTeamExtensionServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/SaasUserAccountServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/SupabaseUserServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java
diff --git a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java
new file mode 100644
index 000000000..bab91affc
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java
@@ -0,0 +1,1035 @@
+package stirling.software.saas.ai.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.ai.controller.AiCreateController.AiCreateSessionResponse;
+import stirling.software.saas.ai.controller.AiCreateController.AiCreateSessionSummary;
+import stirling.software.saas.ai.controller.AiCreateController.CreateSessionRequest;
+import stirling.software.saas.ai.controller.AiCreateController.CreateSessionResponse;
+import stirling.software.saas.ai.controller.AiCreateController.DraftRequest;
+import stirling.software.saas.ai.controller.AiCreateController.DraftSection;
+import stirling.software.saas.ai.controller.AiCreateController.OutlineRequest;
+import stirling.software.saas.ai.controller.AiCreateController.RepromptRequest;
+import stirling.software.saas.ai.controller.AiCreateController.TemplateRequest;
+import stirling.software.saas.ai.model.AiCreateSession;
+import stirling.software.saas.ai.model.AiCreateSessionStatus;
+import stirling.software.saas.ai.repository.AiCreateSessionRepository.AiCreateSessionSummaryProjection;
+import stirling.software.saas.ai.service.AiCreateProxyService;
+import stirling.software.saas.ai.service.AiCreateSessionService;
+import stirling.software.saas.payg.charge.ChargeContext;
+import stirling.software.saas.payg.charge.JobChargeService;
+import stirling.software.saas.payg.model.BillingCategory;
+import stirling.software.saas.payg.model.JobSource;
+import stirling.software.saas.payg.model.ProcessType;
+import stirling.software.saas.service.CreditService;
+import stirling.software.saas.service.TeamCreditService;
+import stirling.software.saas.util.CreditHeaderUtils;
+
+/**
+ * Pure unit tests for {@link AiCreateController}. All collaborators are mocked; the controller's
+ * handler methods are invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
+ *
+ *
The controller reads {@code SecurityContextHolder} in the charge + credit-header paths, so
+ * each relevant test seeds an authentication and {@link #clearSecurityContext()} resets it
+ * afterwards.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AiCreateControllerTest {
+
+ @Mock private AiCreateSessionService sessionService;
+ @Mock private AiCreateProxyService proxyService;
+ @Mock private CreditService creditService;
+ @Mock private TeamCreditService teamCreditService;
+ @Mock private UserRepository userRepository;
+ @Mock private CreditHeaderUtils creditHeaderUtils;
+ @Mock private JobChargeService jobChargeService;
+
+ private AiCreateController controller;
+
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ controller =
+ new AiCreateController(
+ sessionService,
+ proxyService,
+ creditService,
+ teamCreditService,
+ userRepository,
+ creditHeaderUtils,
+ jobChargeService);
+ }
+
+ @AfterEach
+ void clearSecurityContext() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // createSession + chargeForCreate
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("createSession")
+ class CreateSession {
+
+ @Test
+ @DisplayName("happy path returns 200 with the new sessionId and charges one AI unit")
+ void createSession_happyPath_returnsIdAndCharges() {
+ authenticateWeb(userWithTeam(7L, 100L));
+ AiCreateSession created = session("sess-1", "user-x");
+ when(sessionService.createSession(
+ "write a report", "letter", "tmpl-1", "tex", "preview"))
+ .thenReturn(created);
+
+ CreateSessionRequest req =
+ new CreateSessionRequest(
+ "write a report", "letter", "tmpl-1", "tex", "preview");
+ ResponseEntity resp = controller.createSession(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
+ verify(sessionService)
+ .createSession("write a report", "letter", "tmpl-1", "tex", "preview");
+ }
+
+ @Test
+ @DisplayName("WEB auth charges a single AI unit with WEB source and the user's team")
+ void createSession_webAuth_chargesAiUnitWithWebSource() {
+ authenticateWeb(userWithTeam(7L, 100L));
+ when(sessionService.createSession(any(), any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.createSession(new CreateSessionRequest("p", null, null, null, null));
+
+ ArgumentCaptor ctx = ArgumentCaptor.forClass(ChargeContext.class);
+ verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
+ ChargeContext c = ctx.getValue();
+ assertThat(c.ownerUserId()).isEqualTo(7L);
+ assertThat(c.ownerTeamId()).isEqualTo(100L);
+ assertThat(c.source()).isEqualTo(JobSource.WEB);
+ assertThat(c.processType()).isEqualTo(ProcessType.SINGLE_TOOL);
+ assertThat(c.billingCategory()).isEqualTo(BillingCategory.AI);
+ }
+
+ @Test
+ @DisplayName("API-key auth charges with API source (AI usage billed the same as web)")
+ void createSession_apiKeyAuth_chargesAiUnitWithApiSource() {
+ User user = userWithTeam(7L, 100L);
+ authenticateApiKey(user);
+ when(sessionService.createSession(any(), any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.createSession(new CreateSessionRequest("p", null, null, null, null));
+
+ ArgumentCaptor ctx = ArgumentCaptor.forClass(ChargeContext.class);
+ verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
+ assertThat(ctx.getValue().source()).isEqualTo(JobSource.API);
+ assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI);
+ }
+
+ @Test
+ @DisplayName("null prompt is rejected with 400 and never reaches the service")
+ void createSession_nullPrompt_throwsBadRequest() {
+ CreateSessionRequest req = new CreateSessionRequest(null, null, null, null, null);
+
+ assertThatThrownBy(() -> controller.createSession(req))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ e ->
+ assertThat(((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST));
+ verifyNoInteractions(sessionService);
+ verifyNoInteractions(jobChargeService);
+ }
+
+ @Test
+ @DisplayName("blank/whitespace prompt is rejected with 400")
+ void createSession_blankPrompt_throwsBadRequest() {
+ CreateSessionRequest req = new CreateSessionRequest(" ", null, null, null, null);
+
+ assertThatThrownBy(() -> controller.createSession(req))
+ .isInstanceOf(ResponseStatusException.class);
+ verifyNoInteractions(sessionService);
+ verifyNoInteractions(jobChargeService);
+ }
+
+ @Test
+ @DisplayName("no authentication: session still created, charge is skipped (no NPE)")
+ void createSession_noAuth_skipsChargeButCreatesSession() {
+ SecurityContextHolder.clearContext();
+ when(sessionService.createSession(any(), any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ ResponseEntity resp =
+ controller.createSession(new CreateSessionRequest("p", null, null, null, null));
+
+ assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
+ verify(jobChargeService, never())
+ .chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt());
+ }
+
+ @Test
+ @DisplayName("user has no team: charge is skipped (free-grant accounting needs a team)")
+ void createSession_userWithoutTeam_skipsCharge() {
+ User user = new User();
+ user.setId(7L);
+ // No team.
+ authenticateWeb(user);
+ when(sessionService.createSession(any(), any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.createSession(new CreateSessionRequest("p", null, null, null, null));
+
+ verify(jobChargeService, never())
+ .chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt());
+ }
+
+ @Test
+ @DisplayName("charge failure is best-effort: session is still returned to the caller")
+ void createSession_chargeThrows_sessionStillSucceeds() {
+ authenticateWeb(userWithTeam(7L, 100L));
+ when(sessionService.createSession(any(), any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+ when(jobChargeService.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt()))
+ .thenThrow(new IllegalStateException("stripe down"));
+
+ ResponseEntity resp =
+ controller.createSession(new CreateSessionRequest("p", null, null, null, null));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // deleteSession
+ // ----------------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("deleteSession returns 204 and delegates to the service")
+ void deleteSession_returnsNoContentAndDelegates() {
+ ResponseEntity resp = controller.deleteSession("sess-1");
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
+ assertThat(resp.getBody()).isNull();
+ verify(sessionService).deleteSessionForCurrentUser("sess-1");
+ }
+
+ @Test
+ @DisplayName("deleteSession propagates a not-found from the service")
+ void deleteSession_propagatesServiceError() {
+ org.mockito.Mockito.doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND))
+ .when(sessionService)
+ .deleteSessionForCurrentUser("missing");
+
+ assertThatThrownBy(() -> controller.deleteSession("missing"))
+ .isInstanceOf(ResponseStatusException.class);
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // getSession + toResponse mapping
+ // ----------------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("getSession maps every entity field onto the response record")
+ void getSession_mapsAllFields() {
+ AiCreateSession s = session("sess-1", "user-x");
+ s.setDocType("letter");
+ s.setTemplateId("tmpl-1");
+ s.setTemplateTex("\\documentclass{}");
+ s.setPreviewTex("preview-tex");
+ s.setPromptInitial("first prompt");
+ s.setPromptLatest("latest prompt");
+ s.setOutlineText("- one\n- two");
+ s.setOutlineFilename("outline.txt");
+ s.setOutlineApproved(true);
+ s.setOutlineConstraints("{\"tone\":\"formal\"}");
+ s.setDraftSections("[{\"label\":\"Intro\",\"value\":\"hi\"}]");
+ s.setPolishedLatex("\\section{Intro}");
+ s.setPdfUrl("https://signed/url.pdf");
+ Instant created = Instant.parse("2024-01-01T00:00:00Z");
+ Instant updated = Instant.parse("2024-01-02T00:00:00Z");
+ s.setCreatedAt(created);
+ s.setUpdatedAt(updated);
+ s.setStatus(AiCreateSessionStatus.DRAFT_READY);
+ when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
+
+ ResponseEntity resp = controller.getSession("sess-1");
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ AiCreateSessionResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.sessionId()).isEqualTo("sess-1");
+ assertThat(body.userId()).isEqualTo("user-x");
+ assertThat(body.docType()).isEqualTo("letter");
+ assertThat(body.templateId()).isEqualTo("tmpl-1");
+ assertThat(body.templateTex()).isEqualTo("\\documentclass{}");
+ assertThat(body.previewTex()).isEqualTo("preview-tex");
+ assertThat(body.promptInitial()).isEqualTo("first prompt");
+ assertThat(body.promptLatest()).isEqualTo("latest prompt");
+ assertThat(body.outlineText()).isEqualTo("- one\n- two");
+ assertThat(body.outlineFilename()).isEqualTo("outline.txt");
+ assertThat(body.outlineApproved()).isTrue();
+ assertThat(body.outlineConstraints()).containsEntry("tone", "formal");
+ assertThat(body.draftSections()).containsExactly(new DraftSection("Intro", "hi"));
+ assertThat(body.polishedLatex()).isEqualTo("\\section{Intro}");
+ assertThat(body.pdfUrl()).isEqualTo("https://signed/url.pdf");
+ assertThat(body.createdAt()).isEqualTo(created);
+ assertThat(body.updatedAt()).isEqualTo(updated);
+ assertThat(body.status()).isEqualTo("DRAFT_READY");
+ }
+
+ @Test
+ @DisplayName("getSession with null status maps status to null and null payloads to null")
+ void getSession_nullStatusAndPayloads_mapToNull() {
+ AiCreateSession s = session("sess-1", "user-x");
+ s.setStatus(null);
+ s.setOutlineConstraints(null);
+ s.setDraftSections(null);
+ when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
+
+ AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.status()).isNull();
+ assertThat(body.outlineConstraints()).isNull();
+ assertThat(body.draftSections()).isNull();
+ }
+
+ @Test
+ @DisplayName("getSession with blank payloads parses to null rather than throwing")
+ void getSession_blankPayloads_mapToNull() {
+ AiCreateSession s = session("sess-1", "user-x");
+ s.setOutlineConstraints(" ");
+ s.setDraftSections("");
+ when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
+
+ AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
+
+ assertThat(body.outlineConstraints()).isNull();
+ assertThat(body.draftSections()).isNull();
+ }
+
+ @Test
+ @DisplayName("getSession with malformed JSON payloads degrades to null (logged, not thrown)")
+ void getSession_malformedPayloads_mapToNull() {
+ AiCreateSession s = session("sess-1", "user-x");
+ s.setOutlineConstraints("{not-valid-json");
+ s.setDraftSections("[oops");
+ when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
+
+ AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
+
+ assertThat(body.outlineConstraints()).isNull();
+ assertThat(body.draftSections()).isNull();
+ }
+
+ @Test
+ @DisplayName("getSession propagates a not-found from the service")
+ void getSession_notFound_propagates() {
+ when(sessionService.getSessionForCurrentUser("missing"))
+ .thenThrow(
+ new ResponseStatusException(HttpStatus.NOT_FOUND, "AI session not found"));
+
+ assertThatThrownBy(() -> controller.getSession("missing"))
+ .isInstanceOf(ResponseStatusException.class);
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // listSessions + toSummary mapping + page/size clamping
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("listSessions")
+ class ListSessions {
+
+ @Test
+ @DisplayName("maps projections to summaries and forwards includeDrafts")
+ void listSessions_mapsProjections() {
+ AiCreateSessionSummaryProjection p =
+ projection(
+ "sess-1",
+ "letter",
+ "tmpl-1",
+ "latest",
+ "initial",
+ AiCreateSessionStatus.SAVED,
+ "https://pdf",
+ Instant.parse("2024-01-01T00:00:00Z"),
+ Instant.parse("2024-01-02T00:00:00Z"));
+ when(sessionService.listSessionSummariesForCurrentUser(any(), eq(true)))
+ .thenReturn(List.of(p));
+
+ ResponseEntity> resp =
+ controller.listSessions(0, 10, true);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).hasSize(1);
+ AiCreateSessionSummary summary = resp.getBody().get(0);
+ assertThat(summary.sessionId()).isEqualTo("sess-1");
+ assertThat(summary.docType()).isEqualTo("letter");
+ assertThat(summary.templateId()).isEqualTo("tmpl-1");
+ assertThat(summary.promptLatest()).isEqualTo("latest");
+ assertThat(summary.promptInitial()).isEqualTo("initial");
+ assertThat(summary.status()).isEqualTo("SAVED");
+ assertThat(summary.pdfUrl()).isEqualTo("https://pdf");
+ }
+
+ @Test
+ @DisplayName("projection with null status maps to a null status string")
+ void listSessions_nullStatus_mapsToNull() {
+ AiCreateSessionSummaryProjection p =
+ projection("s", null, null, null, null, null, null, null, null);
+ when(sessionService.listSessionSummariesForCurrentUser(any(), eq(false)))
+ .thenReturn(List.of(p));
+
+ AiCreateSessionSummary summary = controller.listSessions(0, 10, false).getBody().get(0);
+
+ assertThat(summary.status()).isNull();
+ }
+
+ @Test
+ @DisplayName("negative page is clamped to 0")
+ void listSessions_negativePage_clampedToZero() {
+ when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
+ .thenReturn(List.of());
+
+ controller.listSessions(-5, 10, false);
+
+ ArgumentCaptor pr =
+ ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
+ verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
+ assertThat(pr.getValue().getPageNumber()).isZero();
+ }
+
+ @Test
+ @DisplayName("size above 50 is capped at 50")
+ void listSessions_oversizeSize_cappedAt50() {
+ when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
+ .thenReturn(List.of());
+
+ controller.listSessions(0, 9999, false);
+
+ ArgumentCaptor pr =
+ ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
+ verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
+ assertThat(pr.getValue().getPageSize()).isEqualTo(50);
+ }
+
+ @Test
+ @DisplayName("size below 1 is floored to 1")
+ void listSessions_zeroSize_flooredToOne() {
+ when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
+ .thenReturn(List.of());
+
+ controller.listSessions(0, 0, false);
+
+ ArgumentCaptor pr =
+ ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
+ verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
+ assertThat(pr.getValue().getPageSize()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("empty result yields an empty list, not null")
+ void listSessions_empty_returnsEmptyList() {
+ when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
+ .thenReturn(List.of());
+
+ ResponseEntity> resp =
+ controller.listSessions(0, 10, false);
+
+ assertThat(resp.getBody()).isEmpty();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // updateOutline
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("updateOutline")
+ class UpdateOutline {
+
+ @Test
+ @DisplayName("serializes constraints to JSON and forwards text + filename")
+ void updateOutline_serializesConstraints() {
+ AiCreateSession updated = session("sess-1", "user-x");
+ when(sessionService.updateOutline(eq("sess-1"), eq("the outline"), eq("o.txt"), any()))
+ .thenReturn(updated);
+
+ OutlineRequest req =
+ new OutlineRequest("the outline", "o.txt", Map.of("tone", "formal"));
+ ResponseEntity resp = controller.updateOutline("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ ArgumentCaptor payload = ArgumentCaptor.forClass(String.class);
+ verify(sessionService)
+ .updateOutline(eq("sess-1"), eq("the outline"), eq("o.txt"), payload.capture());
+ assertThat(payload.getValue()).contains("\"tone\":\"formal\"");
+ }
+
+ @Test
+ @DisplayName("null constraints forward a null payload (use AI-generated outline)")
+ void updateOutline_nullConstraints_forwardsNullPayload() {
+ when(sessionService.updateOutline(any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.updateOutline("sess-1", new OutlineRequest("text", null, null));
+
+ verify(sessionService).updateOutline("sess-1", "text", null, null);
+ }
+
+ @Test
+ @DisplayName("empty outline string is allowed (signals AI-generated outline)")
+ void updateOutline_emptyOutlineText_isAllowed() {
+ when(sessionService.updateOutline(any(), any(), any(), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.updateOutline("sess-1", new OutlineRequest("", null, null));
+
+ verify(sessionService).updateOutline("sess-1", "", null, null);
+ }
+
+ @Test
+ @DisplayName("null outline text is rejected with 400 before touching the service")
+ void updateOutline_nullText_throwsBadRequest() {
+ OutlineRequest req = new OutlineRequest(null, "o.txt", Map.of("a", "b"));
+
+ assertThatThrownBy(() -> controller.updateOutline("sess-1", req))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ e ->
+ assertThat(((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST));
+ verifyNoInteractions(sessionService);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // reprompt
+ // ----------------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("reprompt forwards the prompt and returns the mapped session")
+ void reprompt_forwardsPrompt() {
+ AiCreateSession s = session("sess-1", "user-x");
+ s.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
+ when(sessionService.reprompt("sess-1", "new prompt")).thenReturn(s);
+
+ ResponseEntity resp =
+ controller.reprompt("sess-1", new RepromptRequest("new prompt"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
+ assertThat(resp.getBody().status()).isEqualTo("OUTLINE_PENDING");
+ verify(sessionService).reprompt("sess-1", "new prompt");
+ }
+
+ @Test
+ @DisplayName("reprompt with null prompt is rejected with 400")
+ void reprompt_nullPrompt_throwsBadRequest() {
+ assertThatThrownBy(() -> controller.reprompt("sess-1", new RepromptRequest(null)))
+ .isInstanceOf(ResponseStatusException.class);
+ verifyNoInteractions(sessionService);
+ }
+
+ @Test
+ @DisplayName("reprompt with blank prompt is rejected with 400")
+ void reprompt_blankPrompt_throwsBadRequest() {
+ assertThatThrownBy(() -> controller.reprompt("sess-1", new RepromptRequest(" ")))
+ .isInstanceOf(ResponseStatusException.class);
+ verifyNoInteractions(sessionService);
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // updateDraft
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("updateDraft")
+ class UpdateDraft {
+
+ @Test
+ @DisplayName("serializes draft sections to a JSON array and forwards it")
+ void updateDraft_serializesSections() {
+ when(sessionService.updateDraftSections(eq("sess-1"), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ DraftRequest req =
+ new DraftRequest(
+ List.of(
+ new DraftSection("Intro", "hi"),
+ new DraftSection("Body", "x")));
+ controller.updateDraft("sess-1", req);
+
+ ArgumentCaptor payload = ArgumentCaptor.forClass(String.class);
+ verify(sessionService).updateDraftSections(eq("sess-1"), payload.capture());
+ assertThat(payload.getValue()).contains("\"label\":\"Intro\"");
+ assertThat(payload.getValue()).contains("\"value\":\"hi\"");
+ }
+
+ @Test
+ @DisplayName("empty list is allowed and serialized to []")
+ void updateDraft_emptyList_serializesToEmptyArray() {
+ when(sessionService.updateDraftSections(eq("sess-1"), any()))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.updateDraft("sess-1", new DraftRequest(List.of()));
+
+ verify(sessionService).updateDraftSections("sess-1", "[]");
+ }
+
+ @Test
+ @DisplayName("null draft sections is rejected with 400")
+ void updateDraft_nullSections_throwsBadRequest() {
+ assertThatThrownBy(() -> controller.updateDraft("sess-1", new DraftRequest(null)))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ e ->
+ assertThat(((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST));
+ verifyNoInteractions(sessionService);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // updateTemplate
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("updateTemplate")
+ class UpdateTemplate {
+
+ @Test
+ @DisplayName("docType only is accepted and forwarded")
+ void updateTemplate_docTypeOnly() {
+ when(sessionService.updateTemplate("sess-1", "letter", null))
+ .thenReturn(session("sess-1", "user-x"));
+
+ ResponseEntity resp =
+ controller.updateTemplate("sess-1", new TemplateRequest("letter", null));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(sessionService).updateTemplate("sess-1", "letter", null);
+ }
+
+ @Test
+ @DisplayName("templateId only is accepted and forwarded")
+ void updateTemplate_templateIdOnly() {
+ when(sessionService.updateTemplate("sess-1", null, "tmpl-9"))
+ .thenReturn(session("sess-1", "user-x"));
+
+ controller.updateTemplate("sess-1", new TemplateRequest(null, "tmpl-9"));
+
+ verify(sessionService).updateTemplate("sess-1", null, "tmpl-9");
+ }
+
+ @Test
+ @DisplayName("both docType and templateId null is rejected with 400")
+ void updateTemplate_bothNull_throwsBadRequest() {
+ assertThatThrownBy(
+ () ->
+ controller.updateTemplate(
+ "sess-1", new TemplateRequest(null, null)))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ e ->
+ assertThat(((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST));
+ verifyNoInteractions(sessionService);
+ }
+
+ @Test
+ @DisplayName("both docType and templateId blank is rejected with 400")
+ void updateTemplate_bothBlank_throwsBadRequest() {
+ assertThatThrownBy(
+ () ->
+ controller.updateTemplate(
+ "sess-1", new TemplateRequest(" ", "")))
+ .isInstanceOf(ResponseStatusException.class);
+ verifyNoInteractions(sessionService);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // fillFields (proxy, no credit header)
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("fillFields")
+ class FillFields {
+
+ @Test
+ @DisplayName("checks ownership, proxies POST, copies headers, and streams the body through")
+ void fillFields_proxiesAndStreams() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream =
+ upstreamResponse(
+ 200,
+ "section data",
+ httpHeaders(Map.of(HttpHeaders.CONTENT_TYPE, "application/json")));
+ when(proxyService.forward(
+ eq("POST"),
+ eq("/api/create/sessions/sess-1/fields"),
+ eq(req),
+ eq(false)))
+ .thenReturn(upstream);
+
+ ResponseEntity resp = controller.fillFields("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
+ .isEqualTo("application/json");
+ // Ownership guard runs before the proxy.
+ verify(sessionService).getSessionForCurrentUser("sess-1");
+ assertThat(drain(resp.getBody())).isEqualTo("section data");
+ // No credit header on the non-AI-triggering endpoint.
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ }
+
+ @Test
+ @DisplayName("ownership failure short-circuits before proxying")
+ void fillFields_ownershipFailure_doesNotProxy() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(sessionService.getSessionForCurrentUser("sess-1"))
+ .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
+
+ assertThatThrownBy(() -> controller.fillFields("sess-1", req))
+ .isInstanceOf(ResponseStatusException.class);
+ verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
+ }
+
+ @Test
+ @DisplayName("upstream error: returns 503 with a JSON error body, never throws")
+ void fillFields_proxyThrows_returns503() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(proxyService.forward(any(), any(), any(), anyBoolean()))
+ .thenThrow(new java.io.IOException("backend down"));
+
+ ResponseEntity resp = controller.fillFields("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
+ assertThat(drain(resp.getBody())).contains("AI backend unavailable");
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // stream (proxy, accept event-stream + credit header)
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("stream")
+ class Stream {
+
+ @Test
+ @DisplayName("proxies GET as event-stream and adds X-Credits-Remaining for authed user")
+ void stream_addsCreditHeader() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(42);
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream =
+ upstreamResponse(200, "data: hi\n\n", httpHeaders(Map.of()));
+ when(proxyService.forward(
+ eq("GET"), eq("/api/create/sessions/sess-1/stream"), eq(req), eq(true)))
+ .thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ // No upstream Content-Type → defaulted to text/event-stream.
+ assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
+ .isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
+ verify(sessionService).getSessionForCurrentUser("sess-1");
+ assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n");
+ }
+
+ @Test
+ @DisplayName("negative remaining credits suppresses the credit header")
+ void stream_negativeCredits_omitsHeader() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(-1);
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
+ when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ }
+
+ @Test
+ @DisplayName("zero remaining credits still emits the header (>= 0 boundary)")
+ void stream_zeroCredits_emitsHeader() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(0);
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
+ when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0");
+ }
+
+ @Test
+ @DisplayName("unauthenticated context: stream still proxies, no credit header, no NPE")
+ void stream_noAuth_omitsCreditHeader() throws Exception {
+ SecurityContextHolder.clearContext();
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
+ when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ verifyNoInteractions(creditHeaderUtils);
+ }
+
+ @Test
+ @DisplayName("credit-header lookup blowing up does not break the stream response")
+ void stream_creditLookupThrows_stillStreams() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(any(), any(), any()))
+ .thenThrow(new RuntimeException("boom"));
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream =
+ upstreamResponse(200, "payload", httpHeaders(Map.of()));
+ when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ assertThat(drain(resp.getBody())).isEqualTo("payload");
+ }
+
+ @Test
+ @DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins")
+ void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception {
+ SecurityContextHolder.clearContext();
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpResponse upstream =
+ upstreamResponse(
+ 404,
+ "not found",
+ httpHeaders(
+ Map.of(
+ HttpHeaders.CONTENT_TYPE,
+ "text/plain",
+ HttpHeaders.CACHE_CONTROL,
+ "no-cache")));
+ when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
+ .isEqualTo("text/plain");
+ assertThat(resp.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache");
+ }
+
+ @Test
+ @DisplayName("unmappable upstream status code falls back to 502 Bad Gateway")
+ void stream_unresolvableStatus_fallsBackToBadGateway() throws Exception {
+ SecurityContextHolder.clearContext();
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ // 299 is not a defined HttpStatus enum constant → HttpStatus.resolve returns null.
+ HttpResponse upstream =
+ upstreamResponse(299, "weird", httpHeaders(Map.of()));
+ when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
+
+ ResponseEntity resp = controller.stream("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
+ }
+
+ @Test
+ @DisplayName("ownership failure short-circuits before proxy/credit work")
+ void stream_ownershipFailure_doesNotProxy() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(sessionService.getSessionForCurrentUser("sess-1"))
+ .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
+
+ assertThatThrownBy(() -> controller.stream("sess-1", req))
+ .isInstanceOf(ResponseStatusException.class);
+ verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
+ verifyNoInteractions(creditHeaderUtils);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // helpers
+ // ----------------------------------------------------------------------------------------------
+
+ private static AiCreateSession session(String sessionId, String userId) {
+ AiCreateSession s = new AiCreateSession();
+ s.setSessionId(sessionId);
+ s.setUserId(userId);
+ return s;
+ }
+
+ private static User userWithTeam(long userId, long teamId) {
+ User user = new User();
+ user.setId(userId);
+ Team team = new Team();
+ team.setId(teamId);
+ user.setTeam(team);
+ return user;
+ }
+
+ /**
+ * Authenticated WEB principal: 3-arg ctor so isAuthenticated()==true, principal is the User.
+ */
+ private static void authenticateWeb(User user) {
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken(user, null, List.of());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+
+ private static void authenticateApiKey(User user) {
+ ApiKeyAuthenticationToken auth =
+ new ApiKeyAuthenticationToken(user, "the-api-key", List.of());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+
+ private static java.net.http.HttpHeaders httpHeaders(Map single) {
+ Map> multi = new java.util.HashMap<>();
+ single.forEach((k, v) -> multi.put(k, List.of(v)));
+ return java.net.http.HttpHeaders.of(multi, (k, v) -> true);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpResponse upstreamResponse(
+ int status, String body, java.net.http.HttpHeaders headers) {
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.statusCode()).thenReturn(status);
+ when(response.headers()).thenReturn(headers);
+ when(response.body())
+ .thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
+ return response;
+ }
+
+ private static String drain(StreamingResponseBody body) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ body.writeTo(out);
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ private static AiCreateSessionSummaryProjection projection(
+ String sessionId,
+ String docType,
+ String templateId,
+ String promptLatest,
+ String promptInitial,
+ AiCreateSessionStatus status,
+ String pdfUrl,
+ Instant createdAt,
+ Instant updatedAt) {
+ return new AiCreateSessionSummaryProjection() {
+ @Override
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ @Override
+ public String getDocType() {
+ return docType;
+ }
+
+ @Override
+ public String getTemplateId() {
+ return templateId;
+ }
+
+ @Override
+ public String getPromptLatest() {
+ return promptLatest;
+ }
+
+ @Override
+ public String getPromptInitial() {
+ return promptInitial;
+ }
+
+ @Override
+ public AiCreateSessionStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ public String getPdfUrl() {
+ return pdfUrl;
+ }
+
+ @Override
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ @Override
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+ };
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateInternalControllerTest.java b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateInternalControllerTest.java
new file mode 100644
index 000000000..f5af6ce48
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateInternalControllerTest.java
@@ -0,0 +1,457 @@
+package stirling.software.saas.ai.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.server.ResponseStatusException;
+
+import stirling.software.saas.ai.model.AiCreateSession;
+import stirling.software.saas.ai.model.AiCreateSessionStatus;
+import stirling.software.saas.ai.service.AiCreateSessionService;
+
+/**
+ * Unit tests for {@link AiCreateInternalController}. The controller is a thin internal facade over
+ * {@link AiCreateSessionService}: it maps an entity to a response record, and on update serialises
+ * the JSON-shaped fields (outline constraints / draft sections) before delegating. We mock the
+ * service and assert the {@link ResponseEntity} plus the exact arguments forwarded.
+ */
+@ExtendWith(MockitoExtension.class)
+class AiCreateInternalControllerTest {
+
+ @Mock private AiCreateSessionService sessionService;
+
+ // The controller's @RequiredArgsConstructor only takes sessionService; the ObjectMapper field
+ // is an inline initializer, so a real Jackson instance is exercised by these tests.
+ private AiCreateInternalController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller = new AiCreateInternalController(sessionService);
+ }
+
+ // --- helpers -------------------------------------------------------------------------------
+
+ private static AiCreateSession session(String sessionId) {
+ AiCreateSession s = new AiCreateSession();
+ s.setSessionId(sessionId);
+ s.setUserId("user-1");
+ s.setDocType("report");
+ s.setTemplateId("tmpl-1");
+ s.setTemplateTex("\\documentclass{article}");
+ s.setPreviewTex("preview");
+ s.setPromptInitial("initial prompt");
+ s.setPromptLatest("latest prompt");
+ s.setOutlineText("outline body");
+ s.setOutlineFilename("outline.txt");
+ s.setOutlineApproved(true);
+ s.setPolishedLatex("\\section{x}");
+ s.setPdfUrl("https://example.com/doc.pdf");
+ s.setStatus(AiCreateSessionStatus.DRAFT_READY);
+ return s;
+ }
+
+ @Nested
+ @DisplayName("getSession")
+ class GetSession {
+
+ @Test
+ @DisplayName("returns 200 with the session mapped to a response record")
+ void getSession_mapsAllScalarFields() {
+ AiCreateSession s = session("sess-abc");
+ when(sessionService.getSession("sess-abc")).thenReturn(s);
+
+ ResponseEntity resp =
+ controller.getSession("sess-abc");
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ AiCreateController.AiCreateSessionResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.sessionId()).isEqualTo("sess-abc");
+ assertThat(body.userId()).isEqualTo("user-1");
+ assertThat(body.docType()).isEqualTo("report");
+ assertThat(body.templateId()).isEqualTo("tmpl-1");
+ assertThat(body.templateTex()).isEqualTo("\\documentclass{article}");
+ assertThat(body.previewTex()).isEqualTo("preview");
+ assertThat(body.promptInitial()).isEqualTo("initial prompt");
+ assertThat(body.promptLatest()).isEqualTo("latest prompt");
+ assertThat(body.outlineText()).isEqualTo("outline body");
+ assertThat(body.outlineFilename()).isEqualTo("outline.txt");
+ assertThat(body.outlineApproved()).isTrue();
+ assertThat(body.polishedLatex()).isEqualTo("\\section{x}");
+ assertThat(body.pdfUrl()).isEqualTo("https://example.com/doc.pdf");
+ assertThat(body.status()).isEqualTo("DRAFT_READY");
+
+ verify(sessionService).getSession("sess-abc");
+ }
+
+ @Test
+ @DisplayName("maps a null status to a null status string, not an NPE")
+ void getSession_nullStatus_mapsToNull() {
+ AiCreateSession s = session("sess-null-status");
+ s.setStatus(null);
+ when(sessionService.getSession("sess-null-status")).thenReturn(s);
+
+ ResponseEntity resp =
+ controller.getSession("sess-null-status");
+
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().status()).isNull();
+ }
+
+ @Test
+ @DisplayName("leaves outlineConstraints/draftSections null when the entity stored none")
+ void getSession_noJsonPayloads_yieldsNullCollections() {
+ AiCreateSession s = session("sess-empty-json");
+ s.setOutlineConstraints(null);
+ s.setDraftSections(" "); // blank string is treated as absent
+ when(sessionService.getSession("sess-empty-json")).thenReturn(s);
+
+ AiCreateController.AiCreateSessionResponse body =
+ controller.getSession("sess-empty-json").getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.outlineConstraints()).isNull();
+ assertThat(body.draftSections()).isNull();
+ }
+
+ @Test
+ @DisplayName("parses stored outlineConstraints/draftSections JSON back into the response")
+ void getSession_parsesStoredJsonPayloads() {
+ AiCreateSession s = session("sess-json");
+ s.setOutlineConstraints("{\"tone\":\"formal\",\"pages\":3}");
+ s.setDraftSections("[{\"label\":\"Intro\",\"value\":\"hello\"}]");
+ when(sessionService.getSession("sess-json")).thenReturn(s);
+
+ AiCreateController.AiCreateSessionResponse body =
+ controller.getSession("sess-json").getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.outlineConstraints())
+ .containsEntry("tone", "formal")
+ .containsEntry("pages", 3);
+ assertThat(body.draftSections()).hasSize(1);
+ assertThat(body.draftSections().get(0).label()).isEqualTo("Intro");
+ assertThat(body.draftSections().get(0).value()).isEqualTo("hello");
+ }
+
+ @Test
+ @DisplayName("malformed stored JSON is swallowed and surfaces as null, not a 500")
+ void getSession_malformedJson_returnsNullCollections() {
+ AiCreateSession s = session("sess-bad-json");
+ s.setOutlineConstraints("{not valid json");
+ s.setDraftSections("[also not valid");
+ when(sessionService.getSession("sess-bad-json")).thenReturn(s);
+
+ AiCreateController.AiCreateSessionResponse body =
+ controller.getSession("sess-bad-json").getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.outlineConstraints()).isNull();
+ assertThat(body.draftSections()).isNull();
+ }
+
+ @Test
+ @DisplayName("propagates a 404 ResponseStatusException from the service")
+ void getSession_notFound_propagates() {
+ when(sessionService.getSession("missing"))
+ .thenThrow(
+ new ResponseStatusException(
+ HttpStatus.NOT_FOUND, "AI session not found"));
+
+ assertThatThrownBy(() -> controller.getSession("missing"))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ ex ->
+ assertThat(((ResponseStatusException) ex).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+ }
+ }
+
+ @Nested
+ @DisplayName("updateSession")
+ class UpdateSession {
+
+ @Test
+ @DisplayName("forwards every scalar field and serialises JSON payloads to the service")
+ void updateSession_serialisesPayloadsAndForwardsAllFields() {
+ AiCreateSession updated = session("sess-1");
+ when(sessionService.applyInternalUpdate(
+ eq("sess-1"),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any()))
+ .thenReturn(updated);
+
+ AiCreateInternalController.UpdateSessionRequest req =
+ new AiCreateInternalController.UpdateSessionRequest(
+ "new outline",
+ "new.txt",
+ Boolean.TRUE,
+ Map.of("tone", "casual"),
+ List.of(new AiCreateController.DraftSection("Body", "content")),
+ "\\section{polished}",
+ "https://example.com/out.pdf",
+ "letter",
+ "tmpl-9",
+ AiCreateSessionStatus.POLISHED_READY);
+
+ ResponseEntity resp =
+ controller.updateSession("sess-1", req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
+
+ ArgumentCaptor constraints = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor sections = ArgumentCaptor.forClass(String.class);
+ verify(sessionService)
+ .applyInternalUpdate(
+ eq("sess-1"),
+ eq("new outline"),
+ eq("new.txt"),
+ eq(Boolean.TRUE),
+ constraints.capture(),
+ sections.capture(),
+ eq("\\section{polished}"),
+ eq("https://example.com/out.pdf"),
+ eq("letter"),
+ eq("tmpl-9"),
+ eq(AiCreateSessionStatus.POLISHED_READY));
+
+ // Constraints serialised to a JSON object string carrying the map entry.
+ assertThat(constraints.getValue()).contains("\"tone\"").contains("\"casual\"");
+ // Draft sections serialised to a JSON array string carrying the record fields.
+ assertThat(sections.getValue())
+ .startsWith("[")
+ .contains("\"label\":\"Body\"")
+ .contains("\"value\":\"content\"");
+ }
+
+ @Test
+ @DisplayName(
+ "passes null payloads through when outline constraints / draft sections absent")
+ void updateSession_nullCollections_forwardsNullPayloads() {
+ AiCreateSession updated = session("sess-2");
+ when(sessionService.applyInternalUpdate(
+ eq("sess-2"),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull()))
+ .thenReturn(updated);
+
+ AiCreateInternalController.UpdateSessionRequest req =
+ new AiCreateInternalController.UpdateSessionRequest(
+ null, null, null, null, null, null, null, null, null, null);
+
+ ResponseEntity resp =
+ controller.updateSession("sess-2", req);
+
+ assertThat(resp.getBody()).isNotNull();
+ // Both JSON-shaped fields forwarded as null (not "null" strings) since they were
+ // absent.
+ verify(sessionService)
+ .applyInternalUpdate(
+ eq("sess-2"),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull());
+ }
+
+ @Test
+ @DisplayName("serialises an empty constraints map / sections list to '{}' and '[]'")
+ void updateSession_emptyCollections_serialiseToEmptyJson() {
+ when(sessionService.applyInternalUpdate(
+ eq("sess-3"),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any()))
+ .thenReturn(session("sess-3"));
+
+ AiCreateInternalController.UpdateSessionRequest req =
+ new AiCreateInternalController.UpdateSessionRequest(
+ null, null, null, Map.of(), List.of(), null, null, null, null, null);
+
+ controller.updateSession("sess-3", req);
+
+ ArgumentCaptor constraints = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor sections = ArgumentCaptor.forClass(String.class);
+ verify(sessionService)
+ .applyInternalUpdate(
+ eq("sess-3"),
+ isNull(),
+ isNull(),
+ isNull(),
+ constraints.capture(),
+ sections.capture(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull(),
+ isNull());
+ // Empty (but present) collections still serialise: distinguishes "absent" from "empty".
+ assertThat(constraints.getValue()).isEqualTo("{}");
+ assertThat(sections.getValue()).isEqualTo("[]");
+ }
+
+ @Test
+ @DisplayName("response reflects the entity the service returns after the update")
+ void updateSession_responseReflectsReturnedEntity() {
+ AiCreateSession returned = session("sess-4");
+ returned.setStatus(AiCreateSessionStatus.SAVED);
+ returned.setPdfUrl("https://example.com/final.pdf");
+ when(sessionService.applyInternalUpdate(
+ eq("sess-4"),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any()))
+ .thenReturn(returned);
+
+ AiCreateInternalController.UpdateSessionRequest req =
+ new AiCreateInternalController.UpdateSessionRequest(
+ "x", null, null, null, null, null, null, null, null, null);
+
+ AiCreateController.AiCreateSessionResponse body =
+ controller.updateSession("sess-4", req).getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.status()).isEqualTo("SAVED");
+ assertThat(body.pdfUrl()).isEqualTo("https://example.com/final.pdf");
+ }
+
+ @Test
+ @DisplayName("propagates a 404 when the service cannot find the session to update")
+ void updateSession_notFound_propagates() {
+ when(sessionService.applyInternalUpdate(
+ eq("missing"),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any()))
+ .thenThrow(
+ new ResponseStatusException(
+ HttpStatus.NOT_FOUND, "AI session not found"));
+
+ AiCreateInternalController.UpdateSessionRequest req =
+ new AiCreateInternalController.UpdateSessionRequest(
+ "x", null, null, null, null, null, null, null, null, null);
+
+ assertThatThrownBy(() -> controller.updateSession("missing", req))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ ex ->
+ assertThat(((ResponseStatusException) ex).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("round-trip: serialised draft sections parse back identically in the response")
+ void updateSession_draftSectionsRoundTrip() {
+ // Service echoes back the payload it was handed so we can confirm serialise -> store ->
+ // parse is lossless for the DraftSection shape.
+ when(sessionService.applyInternalUpdate(
+ eq("sess-rt"),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any(),
+ any()))
+ .thenAnswer(
+ inv -> {
+ AiCreateSession s = session("sess-rt");
+ s.setOutlineConstraints((String) inv.getArgument(4));
+ s.setDraftSections((String) inv.getArgument(5));
+ return s;
+ });
+
+ AiCreateInternalController.UpdateSessionRequest req =
+ new AiCreateInternalController.UpdateSessionRequest(
+ null,
+ null,
+ null,
+ Map.of("depth", "deep"),
+ List.of(
+ new AiCreateController.DraftSection("A", "1"),
+ new AiCreateController.DraftSection("B", "2")),
+ null,
+ null,
+ null,
+ null,
+ null);
+
+ AiCreateController.AiCreateSessionResponse body =
+ controller.updateSession("sess-rt", req).getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.outlineConstraints()).containsEntry("depth", "deep");
+ assertThat(body.draftSections()).hasSize(2);
+ assertThat(body.draftSections().get(0).label()).isEqualTo("A");
+ assertThat(body.draftSections().get(0).value()).isEqualTo("1");
+ assertThat(body.draftSections().get(1).label()).isEqualTo("B");
+ assertThat(body.draftSections().get(1).value()).isEqualTo("2");
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java
new file mode 100644
index 000000000..51b098df1
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java
@@ -0,0 +1,802 @@
+package stirling.software.saas.ai.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.ai.service.AiProxyService;
+import stirling.software.saas.service.CreditService;
+import stirling.software.saas.service.TeamCreditService;
+import stirling.software.saas.util.CreditHeaderUtils;
+
+/**
+ * Pure unit tests for {@link AiProxyController}. Every collaborator is mocked; each handler is
+ * invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
+ *
+ * All endpoints funnel through one private {@code proxy(method, path, request,
+ * acceptEventStream, includeCreditsHeader)} helper, so the suite has two halves:
+ *
+ *
+ * per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a
+ * given handler forwards (the path-mapping surface), and
+ * behavioural tests around the single shared {@code proxy} body: header copy, status
+ * resolution, the 503 error fallback, and the credit-header path keyed off {@code
+ * includeCreditsHeader}.
+ *
+ *
+ * The credit-header branch reads {@code SecurityContextHolder}, so the relevant tests seed an
+ * authentication and {@link #clearSecurityContext()} resets it afterwards.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AiProxyControllerTest {
+
+ @Mock private AiProxyService aiProxyService;
+ @Mock private CreditService creditService;
+ @Mock private TeamCreditService teamCreditService;
+ @Mock private UserRepository userRepository;
+ @Mock private CreditHeaderUtils creditHeaderUtils;
+
+ private AiProxyController controller;
+
+ @org.junit.jupiter.api.BeforeEach
+ void setUp() {
+ controller =
+ new AiProxyController(
+ aiProxyService,
+ creditService,
+ teamCreditService,
+ userRepository,
+ creditHeaderUtils);
+ }
+
+ @AfterEach
+ void clearSecurityContext() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // Endpoint path/method mapping — each handler pins the exact upstream contract it forwards.
+ // None of these endpoints request the credit header except chat/* and edit message; see the
+ // dedicated credit-header section for those.
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("endpoint → upstream (method, path, acceptEventStream) mapping")
+ class EndpointMapping {
+
+ @Test
+ @DisplayName("generateSection POSTs to /api/generate_section, non-stream, no credit header")
+ void generateSection() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/generate_section", req, false, ok("body"));
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(aiProxyService).forward("POST", "/api/generate_section", req, false);
+ assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
+ }
+
+ @Test
+ @DisplayName("generateAllSections POSTs to /api/generate_all_sections")
+ void generateAllSections() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/generate_all_sections", req, false, ok("body"));
+
+ controller.generateAllSections(req);
+
+ verify(aiProxyService).forward("POST", "/api/generate_all_sections", req, false);
+ }
+
+ @Test
+ @DisplayName("intentCheck POSTs to /api/intent/check")
+ void intentCheck() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/intent/check", req, false, ok("body"));
+
+ controller.intentCheck(req);
+
+ verify(aiProxyService).forward("POST", "/api/intent/check", req, false);
+ }
+
+ @Test
+ @DisplayName("pdfAnswer POSTs to /api/pdf/answer")
+ void pdfAnswer() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/pdf/answer", req, false, ok("body"));
+
+ controller.pdfAnswer(req);
+
+ verify(aiProxyService).forward("POST", "/api/pdf/answer", req, false);
+ }
+
+ @Test
+ @DisplayName("progressiveRender POSTs to /api/progressive_render")
+ void progressiveRender() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/progressive_render", req, false, ok("body"));
+
+ controller.progressiveRender(req);
+
+ verify(aiProxyService).forward("POST", "/api/progressive_render", req, false);
+ }
+
+ @Test
+ @DisplayName("versions GETs /api/versions/{userId} with the path variable interpolated")
+ void versions() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("GET", "/api/versions/user-42", req, false, ok("body"));
+
+ controller.versions("user-42", req);
+
+ verify(aiProxyService).forward("GET", "/api/versions/user-42", req, false);
+ }
+
+ @Test
+ @DisplayName("style (GET) GETs /api/style/{userId}")
+ void styleGet() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("GET", "/api/style/user-42", req, false, ok("body"));
+
+ controller.style("user-42", req);
+
+ verify(aiProxyService).forward("GET", "/api/style/user-42", req, false);
+ }
+
+ @Test
+ @DisplayName("updateStyle POSTs /api/style/{userId}")
+ void updateStyle() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/style/user-42", req, false, ok("body"));
+
+ controller.updateStyle("user-42", req);
+
+ verify(aiProxyService).forward("POST", "/api/style/user-42", req, false);
+ }
+
+ @Test
+ @DisplayName("importTemplate POSTs /api/import_template")
+ void importTemplate() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/import_template", req, false, ok("body"));
+
+ controller.importTemplate(req);
+
+ verify(aiProxyService).forward("POST", "/api/import_template", req, false);
+ }
+
+ @Test
+ @DisplayName("createEditSession POSTs /api/edit/sessions")
+ void createEditSession() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/edit/sessions", req, false, ok("body"));
+
+ controller.createEditSession(req);
+
+ verify(aiProxyService).forward("POST", "/api/edit/sessions", req, false);
+ }
+
+ @Test
+ @DisplayName("editSessionAttachment POSTs /api/edit/sessions/{id}/attachments")
+ void editSessionAttachment() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/edit/sessions/sess-9/attachments", req, false, ok("body"));
+
+ controller.editSessionAttachment("sess-9", req);
+
+ verify(aiProxyService)
+ .forward("POST", "/api/edit/sessions/sess-9/attachments", req, false);
+ }
+
+ @Test
+ @DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream")
+ void runEditSession() throws Exception {
+ HttpServletRequest req = req();
+ // acceptEventStream == true here (and no credit header).
+ stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n"));
+
+ controller.runEditSession("sess-9", req);
+
+ verify(aiProxyService).forward("POST", "/api/edit/sessions/sess-9/run", req, true);
+ }
+
+ @Test
+ @DisplayName("pdfEditorDocument GETs /api/pdf-editor/document")
+ void pdfEditorDocument() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("GET", "/api/pdf-editor/document", req, false, ok("body"));
+
+ controller.pdfEditorDocument(req);
+
+ verify(aiProxyService).forward("GET", "/api/pdf-editor/document", req, false);
+ }
+
+ @Test
+ @DisplayName("pdfEditorUpload POSTs /api/pdf-editor/upload")
+ void pdfEditorUpload() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/pdf-editor/upload", req, false, ok("body"));
+
+ controller.pdfEditorUpload(req);
+
+ verify(aiProxyService).forward("POST", "/api/pdf-editor/upload", req, false);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // output(**) — derives the upstream path from the raw request URI minus the proxy prefix.
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("output(**) wildcard path derivation")
+ class OutputPathDerivation {
+
+ @Test
+ @DisplayName(
+ "strips the contextPath + /api/v1/ai/output/ prefix and forwards the remainder")
+ void output_stripsPrefixAndForwardsRemainder() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getContextPath()).thenReturn("");
+ when(req.getRequestURI()).thenReturn("/api/v1/ai/output/foo/bar.png");
+ stubForward("GET", "/output/foo/bar.png", req, false, ok("img-bytes"));
+
+ ResponseEntity resp = controller.output(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(aiProxyService).forward("GET", "/output/foo/bar.png", req, false);
+ }
+
+ @Test
+ @DisplayName("honours a non-empty servlet contextPath when computing the prefix")
+ void output_honoursContextPath() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getContextPath()).thenReturn("/stirling");
+ when(req.getRequestURI()).thenReturn("/stirling/api/v1/ai/output/nested/file.pdf");
+ stubForward("GET", "/output/nested/file.pdf", req, false, ok("pdf"));
+
+ controller.output(req);
+
+ verify(aiProxyService).forward("GET", "/output/nested/file.pdf", req, false);
+ }
+
+ @Test
+ @DisplayName("URI not under the expected prefix yields an empty remainder (path /output/)")
+ void output_uriOutsidePrefix_emptyRemainder() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getContextPath()).thenReturn("");
+ // Does not start with /api/v1/ai/output/ → substring branch skipped, path stays "".
+ when(req.getRequestURI()).thenReturn("/totally/different");
+ stubForward("GET", "/output/", req, false, ok("body"));
+
+ controller.output(req);
+
+ verify(aiProxyService).forward("GET", "/output/", req, false);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // Shared proxy body — header copy + status resolution + streaming.
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("proxy() header copy, status resolution and streaming")
+ class ProxyBody {
+
+ @Test
+ @DisplayName("copies the whitelisted upstream headers onto the response")
+ void copiesWhitelistedHeaders() throws Exception {
+ HttpServletRequest req = req();
+ HttpResponse upstream =
+ upstreamResponse(
+ 200,
+ "payload",
+ httpHeaders(
+ Map.of(
+ HttpHeaders.CONTENT_TYPE,
+ "application/json",
+ HttpHeaders.CACHE_CONTROL,
+ "no-cache",
+ "X-Accel-Buffering",
+ "no",
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=a.pdf",
+ HttpHeaders.CONTENT_LENGTH,
+ "7")));
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ HttpHeaders h = resp.getHeaders();
+ assertThat(h.getFirst(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json");
+ assertThat(h.getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache");
+ assertThat(h.getFirst("X-Accel-Buffering")).isEqualTo("no");
+ assertThat(h.getFirst(HttpHeaders.CONTENT_DISPOSITION))
+ .isEqualTo("attachment; filename=a.pdf");
+ assertThat(h.getFirst(HttpHeaders.CONTENT_LENGTH)).isEqualTo("7");
+ }
+
+ @Test
+ @DisplayName("streams the upstream body straight through to the output stream")
+ void streamsBodyThrough() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/generate_section", req, false, ok("hello-stream"));
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(drain(resp.getBody())).isEqualTo("hello-stream");
+ }
+
+ @Test
+ @DisplayName("upstream non-2xx status is passed through verbatim")
+ void passesThroughUpstreamStatus() throws Exception {
+ HttpServletRequest req = req();
+ HttpResponse upstream =
+ upstreamResponse(404, "nope", httpHeaders(Map.of()));
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("unmappable upstream status (299) falls back to 502 Bad Gateway")
+ void unmappableStatus_fallsBackToBadGateway() throws Exception {
+ HttpServletRequest req = req();
+ // 299 is not a defined HttpStatus enum constant → HttpStatus.resolve returns null.
+ HttpResponse upstream =
+ upstreamResponse(299, "weird", httpHeaders(Map.of()));
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
+ }
+
+ @Test
+ @DisplayName(
+ "event-stream endpoint with no upstream Content-Type defaults to text/event-stream")
+ void eventStreamDefaultsContentType() throws Exception {
+ HttpServletRequest req = req();
+ // runEditSession uses acceptEventStream == true.
+ HttpResponse upstream =
+ upstreamResponse(200, "data: x\n\n", httpHeaders(Map.of()));
+ when(aiProxyService.forward(
+ eq("POST"), eq("/api/edit/sessions/s/run"), eq(req), eq(true)))
+ .thenReturn(upstream);
+
+ ResponseEntity resp = controller.runEditSession("s", req);
+
+ assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
+ .isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
+ }
+
+ @Test
+ @DisplayName("event-stream endpoint keeps an explicit upstream Content-Type (no override)")
+ void eventStreamKeepsExplicitContentType() throws Exception {
+ HttpServletRequest req = req();
+ HttpResponse upstream =
+ upstreamResponse(
+ 200,
+ "data: x\n\n",
+ httpHeaders(Map.of(HttpHeaders.CONTENT_TYPE, "text/plain")));
+ when(aiProxyService.forward(
+ eq("POST"), eq("/api/edit/sessions/s/run"), eq(req), eq(true)))
+ .thenReturn(upstream);
+
+ ResponseEntity resp = controller.runEditSession("s", req);
+
+ assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
+ .isEqualTo("text/plain");
+ }
+
+ @Test
+ @DisplayName("non-event-stream endpoint with no upstream Content-Type leaves it unset")
+ void nonEventStream_noContentType_leavesUnset() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/generate_section", req, false, ok("body"));
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getHeaders().containsHeader(HttpHeaders.CONTENT_TYPE)).isFalse();
+ }
+
+ @Test
+ @DisplayName("a header carrying CR/LF injection is dropped, not copied")
+ void crlfInjectionHeaderDropped() throws Exception {
+ HttpServletRequest req = req();
+ HttpResponse upstream =
+ upstreamResponse(
+ 200,
+ "body",
+ httpHeaders(
+ Map.of(
+ HttpHeaders.CACHE_CONTROL,
+ "no-cache\r\nX-Injected: evil")));
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getHeaders().containsHeader(HttpHeaders.CACHE_CONTROL)).isFalse();
+ assertThat(resp.getHeaders().containsHeader("X-Injected")).isFalse();
+ }
+
+ @Test
+ @DisplayName("absent upstream headers are simply omitted (no blank values set)")
+ void absentHeadersOmitted() throws Exception {
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/generate_section", req, false, ok("body"));
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getHeaders().containsHeader(HttpHeaders.CACHE_CONTROL)).isFalse();
+ assertThat(resp.getHeaders().containsHeader(HttpHeaders.CONTENT_DISPOSITION)).isFalse();
+ assertThat(resp.getHeaders().containsHeader("X-Accel-Buffering")).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // Error fallback — any forward() failure becomes a 503 with a JSON error body, never throws.
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("error fallback")
+ class ErrorFallback {
+
+ @Test
+ @DisplayName("IOException from forward yields 503 + JSON error body")
+ void ioException_returns503() throws Exception {
+ HttpServletRequest req = req();
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
+ .thenThrow(new java.io.IOException("backend down"));
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
+ assertThat(drain(resp.getBody())).contains("AI backend unavailable");
+ }
+
+ @Test
+ @DisplayName("InterruptedException from forward also degrades to a 503, never propagates")
+ void interruptedException_returns503() throws Exception {
+ HttpServletRequest req = req();
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
+ .thenThrow(new InterruptedException("interrupted"));
+
+ ResponseEntity resp = controller.generateSection(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ assertThat(drain(resp.getBody())).contains("AI backend unavailable");
+ }
+
+ @Test
+ @DisplayName("a credit-bearing endpoint failing in forward never reaches the credit path")
+ void creditEndpointFailure_skipsCreditWork() throws Exception {
+ HttpServletRequest req = req();
+ authenticateWeb(userWithTeam(7L, 100L));
+ when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
+ .thenThrow(new java.io.IOException("down"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ // The credit header is only added after a successful forward.
+ verifyNoInteractions(creditHeaderUtils);
+ assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // Credit headers — only the chat/* and edit-message endpoints set includeCreditsHeader = true.
+ // ----------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName(
+ "credit-header endpoints (chatRoute / createSmartFolder / chatInfo / editSessionMessage)")
+ class CreditHeaders {
+
+ @Test
+ @DisplayName(
+ "chatRoute POSTs /api/chat/route and adds X-Credits-Remaining for an authed user")
+ void chatRoute_addsCreditHeaders() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(42);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("reply"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
+ verify(aiProxyService).forward("POST", "/api/chat/route", req, false);
+ }
+
+ @Test
+ @DisplayName("createSmartFolder POSTs /api/chat/create-smart-folder with the credit header")
+ void createSmartFolder_addsCreditHeaders() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(5);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("folder"));
+
+ ResponseEntity resp = controller.createSmartFolder(req);
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("5");
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
+ verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false);
+ }
+
+ @Test
+ @DisplayName("chatInfo POSTs /api/chat/info with the credit header")
+ void chatInfo_addsCreditHeaders() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(3);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/info", req, false, ok("info"));
+
+ ResponseEntity resp = controller.chatInfo(req);
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("3");
+ verify(aiProxyService).forward("POST", "/api/chat/info", req, false);
+ }
+
+ @Test
+ @DisplayName(
+ "editSessionMessage POSTs /api/edit/sessions/{id}/messages with the credit header")
+ void editSessionMessage_addsCreditHeaders() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(99);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("msg"));
+
+ ResponseEntity resp =
+ controller.editSessionMessage("sess-9", req);
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("99");
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
+ verify(aiProxyService)
+ .forward("POST", "/api/edit/sessions/sess-9/messages", req, false);
+ }
+
+ @Test
+ @DisplayName("zero remaining credits still emits the header (>= 0 boundary)")
+ void zeroCredits_emitsHeader() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(0);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("reply"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0");
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
+ }
+
+ @Test
+ @DisplayName(
+ "negative remaining credits omits X-Credits-Remaining but still sets the source")
+ void negativeCredits_omitsRemainingButKeepsSource() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
+ .thenReturn(-1);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("reply"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ // X-Credit-Source is set unconditionally once we reach the credit path.
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
+ }
+
+ @Test
+ @DisplayName(
+ "no authentication: credit work is skipped, no credit headers, response still streams")
+ void noAuth_skipsCreditHeaders() throws Exception {
+ SecurityContextHolder.clearContext();
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("reply"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
+ verifyNoInteractions(creditHeaderUtils);
+ assertThat(drain(resp.getBody())).isEqualTo("reply");
+ }
+
+ @Test
+ @DisplayName("an unauthenticated token (isAuthenticated()==false) is treated as no-auth")
+ void unauthenticatedToken_skipsCreditHeaders() throws Exception {
+ // 2-arg ctor → isAuthenticated() == false, so the credit branch short-circuits.
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken("alice", "pw");
+ SecurityContextHolder.getContext().setAuthentication(token);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("reply"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
+ verifyNoInteractions(creditHeaderUtils);
+ }
+
+ @Test
+ @DisplayName("credit-header work blowing up is swallowed; the response still streams")
+ void creditLookupThrows_stillStreams() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(any(), any(), any()))
+ .thenThrow(new RuntimeException("boom"));
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("payload"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ // The catch in addCreditHeaders runs before X-Credit-Source is set, so neither lands.
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
+ assertThat(drain(resp.getBody())).isEqualTo("payload");
+ }
+
+ @Test
+ @DisplayName("resolving the current user failing is swallowed; the response still streams")
+ void getCurrentUserThrows_stillStreams() throws Exception {
+ // EnhancedJwtAuthenticationToken is the only auth type with a getCurrentUser DB lookup,
+ // but a plain authed token whose principal is a User short-circuits there. To exercise
+ // the swallow we make the downstream credit lookup throw (covered above); here we
+ // assert
+ // that a non-User principal that can't be resolved doesn't break the stream.
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken("alice", "pw", List.of());
+ // principal is the String "alice"; getCurrentUser will hit
+ // userRepository.findByUsername.
+ when(userRepository.findByUsername("alice")).thenReturn(java.util.Optional.empty());
+ SecurityContextHolder.getContext().setAuthentication(token);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("payload"));
+
+ ResponseEntity resp = controller.chatRoute(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
+ assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
+ assertThat(drain(resp.getBody())).isEqualTo("payload");
+ verifyNoInteractions(creditHeaderUtils);
+ }
+
+ @Test
+ @DisplayName(
+ "an authed User principal is passed straight to the credit utils (no repo lookup)")
+ void authedUserPrincipal_passedToCreditUtils() throws Exception {
+ User user = userWithTeam(7L, 100L);
+ authenticateWeb(user);
+ when(creditHeaderUtils.getRemainingCredits(any(), any(), any())).thenReturn(11);
+ HttpServletRequest req = req();
+ stubForward("POST", "/api/chat/route", req, false, ok("reply"));
+
+ controller.chatRoute(req);
+
+ // The principal User is forwarded verbatim alongside both credit services.
+ verify(creditHeaderUtils).getRemainingCredits(user, creditService, teamCreditService);
+ verifyNoInteractions(userRepository);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------------
+ // helpers
+ // ----------------------------------------------------------------------------------------------
+
+ /** A bare mocked request — these handlers never read from it directly (the service does). */
+ private static HttpServletRequest req() {
+ return mock(HttpServletRequest.class);
+ }
+
+ private void stubForward(
+ String method,
+ String path,
+ HttpServletRequest req,
+ boolean acceptEventStream,
+ HttpResponse response)
+ throws Exception {
+ when(aiProxyService.forward(eq(method), eq(path), eq(req), eq(acceptEventStream)))
+ .thenReturn(response);
+ }
+
+ private static HttpResponse ok(String body) {
+ return upstreamResponse(200, body, httpHeaders(Map.of()));
+ }
+
+ private static User userWithTeam(long userId, long teamId) {
+ User user = new User();
+ user.setId(userId);
+ Team team = new Team();
+ team.setId(teamId);
+ user.setTeam(team);
+ return user;
+ }
+
+ /**
+ * Authenticated WEB principal: 3-arg ctor so isAuthenticated()==true, principal is the User.
+ */
+ private static void authenticateWeb(User user) {
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken(user, null, List.of());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+
+ private static java.net.http.HttpHeaders httpHeaders(Map single) {
+ Map> multi = new java.util.HashMap<>();
+ single.forEach((k, v) -> multi.put(k, List.of(v)));
+ return java.net.http.HttpHeaders.of(multi, (k, v) -> true);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static HttpResponse upstreamResponse(
+ int status, String body, java.net.http.HttpHeaders headers) {
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.statusCode()).thenReturn(status);
+ when(response.headers()).thenReturn(headers);
+ when(response.body())
+ .thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
+ return response;
+ }
+
+ private static String drain(StreamingResponseBody body) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ body.writeTo(out);
+ return out.toString(StandardCharsets.UTF_8);
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateProxyServiceTest.java b/app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateProxyServiceTest.java
new file mode 100644
index 000000000..39e949505
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateProxyServiceTest.java
@@ -0,0 +1,676 @@
+package stirling.software.saas.ai.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.service.UserService;
+
+/**
+ * Unit tests for {@link AiCreateProxyService}.
+ *
+ * The service forwards an inbound HTTP request to the AI "create" backend. It builds its own
+ * {@link HttpClient} in the constructor (no injection), so each test swaps in a mocked client via
+ * {@link ReflectionTestUtils} and captures the outgoing {@link HttpRequest} to assert on the URL,
+ * method and headers. All collaborators ({@link HttpServletRequest}, {@link UserService}, {@link
+ * UserRepository}) are mocked; no Spring context, DB or real network is involved.
+ *
+ *
Header semantics under test: Content-Type and Authorization are forwarded only when present
+ * and non-blank; X-API-KEY is taken from the inbound header first and otherwise resolved from the
+ * authenticated user (any lookup failure is swallowed); Accept is overridden to {@code
+ * text/event-stream} when SSE is requested. GET/DELETE send no body; other methods stream the
+ * request input stream lazily.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AiCreateProxyServiceTest {
+
+ private static final String BASE_URL = "http://ai-backend:5001";
+
+ @Mock private UserRepository userRepository;
+ @Mock private UserService userService;
+ @Mock private HttpServletRequest request;
+ @Mock private HttpClient httpClient;
+
+ @SuppressWarnings("unchecked")
+ private final HttpResponse response =
+ (HttpResponse) org.mockito.Mockito.mock(HttpResponse.class);
+
+ private AiCreateProxyService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new AiCreateProxyService(BASE_URL, userRepository, userService);
+ // Swap the internally-built client for our mock so no real network call happens.
+ ReflectionTestUtils.setField(service, "httpClient", httpClient);
+ // Default: the mocked client returns our stub response for any send().
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenReturn(response);
+ }
+
+ /** Capture the single HttpRequest the service hands to the client. */
+ private HttpRequest captureSentRequest() throws Exception {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
+ return captor.getValue();
+ }
+
+ private static String header(HttpRequest req, String name) {
+ return req.headers().firstValue(name).orElse(null);
+ }
+
+ /** Build a fresh service backed by the shared mock client for base-URL variations. */
+ private AiCreateProxyService serviceWithBase(String base) {
+ AiCreateProxyService svc = new AiCreateProxyService(base, userRepository, userService);
+ ReflectionTestUtils.setField(svc, "httpClient", httpClient);
+ return svc;
+ }
+
+ @Nested
+ @DisplayName("target URL assembly")
+ class UrlAssembly {
+
+ @Test
+ @DisplayName("joins base + leading-slash path + query string")
+ void joinsBasePathAndQuery() throws Exception {
+ when(request.getQueryString()).thenReturn("model=foo&n=2");
+
+ service.forward("GET", "/v1/chat", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/chat?model=foo&n=2"));
+ }
+
+ @Test
+ @DisplayName("prepends a slash when the path lacks one")
+ void prependsMissingSlash() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("GET", "v1/health", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/health"));
+ }
+
+ @Test
+ @DisplayName("null query string is ignored")
+ void nullQueryIgnored() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("GET", "/v1/ping", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
+ }
+
+ @Test
+ @DisplayName("blank query string is ignored")
+ void blankQueryIgnored() throws Exception {
+ when(request.getQueryString()).thenReturn(" ");
+
+ service.forward("GET", "/v1/ping", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
+ }
+
+ @Test
+ @DisplayName("trailing slash on the configured base URL is trimmed")
+ void trimsTrailingSlashOnBase() throws Exception {
+ AiCreateProxyService svc = serviceWithBase("http://ai-backend:5001/");
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/x"));
+ }
+
+ @Test
+ @DisplayName("surrounding whitespace on the base URL is trimmed")
+ void trimsWhitespaceOnBase() throws Exception {
+ AiCreateProxyService svc = serviceWithBase(" http://ai-backend:5001 ");
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/y", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/y"));
+ }
+
+ @Test
+ @DisplayName("blank base URL falls back to the localhost default")
+ void blankBaseUrlFallsBackToDefault() throws Exception {
+ AiCreateProxyService svc = serviceWithBase(" ");
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/z", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://localhost:5001/v1/z"));
+ }
+
+ @Test
+ @DisplayName("null base URL falls back to the localhost default")
+ void nullBaseUrlFallsBackToDefault() throws Exception {
+ AiCreateProxyService svc = serviceWithBase(null);
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/q", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://localhost:5001/v1/q"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Content-Type header forwarding")
+ class ContentTypeForwarding {
+
+ @Test
+ @DisplayName("forwards a present inbound Content-Type on a body-bearing request")
+ void forwardsPresentContentType() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("application/json");
+ when(request.getInputStream())
+ .thenReturn(servletInputStream("{}".getBytes(StandardCharsets.UTF_8)));
+
+ service.forward("POST", "/v1/chat", request, false);
+
+ assertThat(header(captureSentRequest(), "Content-Type")).isEqualTo("application/json");
+ }
+
+ @Test
+ @DisplayName("omits Content-Type when the inbound value is null")
+ void omitsWhenNull() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("omits Content-Type when the inbound value is blank")
+ void omitsWhenBlank() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("Authorization header forwarding")
+ class AuthorizationForwarding {
+
+ @Test
+ @DisplayName("forwards a present Authorization header verbatim")
+ void forwardsPresentAuth() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn("Bearer abc.def");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "Authorization")).isEqualTo("Bearer abc.def");
+ }
+
+ @Test
+ @DisplayName("omits the header when Authorization is null")
+ void omitsWhenNull() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("omits the header when Authorization is blank")
+ void omitsWhenBlank() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("X-API-KEY resolution")
+ class ApiKeyResolution {
+
+ @Test
+ @DisplayName("uses the X-API-KEY header from the request when present (no user lookup)")
+ void usesRequestHeaderAndSkipsUserLookup() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn("req-key-123");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("req-key-123");
+ // Header short-circuits the authenticated-user fallback entirely.
+ verifyNoInteractions(userService);
+ }
+
+ @Test
+ @DisplayName("falls back to the authenticated user's API key when the header is absent")
+ void fallsBackToUserApiKey() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("alice");
+ when(userService.getApiKeyForUser("alice")).thenReturn("user-key-xyz");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("user-key-xyz");
+ verify(userService).getApiKeyForUser("alice");
+ }
+
+ @Test
+ @DisplayName("falls back to the user key when the inbound header is blank")
+ void blankHeaderTriggersFallback() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(" ");
+ when(userService.getCurrentUsername()).thenReturn("bob");
+ when(userService.getApiKeyForUser("bob")).thenReturn("bob-key");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("bob-key");
+ }
+
+ @Test
+ @DisplayName("no X-API-KEY header is set when there is no authenticated user")
+ void noUser_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ // Username was null/blank, so the key lookup is never attempted.
+ verify(userService, never()).getApiKeyForUser(any());
+ }
+
+ @Test
+ @DisplayName("blank username from the security context yields no header")
+ void blankUsername_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ verify(userService, never()).getApiKeyForUser(any());
+ }
+
+ @Test
+ @DisplayName("a resolved-but-blank user key is not forwarded")
+ void blankResolvedKey_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("carol");
+ when(userService.getApiKeyForUser("carol")).thenReturn("");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("a null resolved user key is not forwarded")
+ void nullResolvedKey_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("dan");
+ when(userService.getApiKeyForUser("dan")).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("an exception while resolving the user key is swallowed; no header forwarded")
+ void userKeyLookupThrows_isSwallowed() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("erin");
+ when(userService.getApiKeyForUser("erin"))
+ .thenThrow(new RuntimeException("key store offline"));
+
+ // Must not propagate: extractUserApiKey() catches and returns null.
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("Accept header handling")
+ class AcceptHandling {
+
+ @Test
+ @DisplayName("acceptEventStream overrides any inbound Accept with text/event-stream")
+ void eventStreamOverrides() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn("application/json");
+
+ service.forward("GET", "/v1/stream", request, true);
+
+ assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
+ }
+
+ @Test
+ @DisplayName("event stream is requested even with no inbound Accept header")
+ void eventStreamWithoutInboundAccept() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn(null);
+
+ service.forward("GET", "/v1/stream", request, true);
+
+ assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
+ }
+
+ @Test
+ @DisplayName("passes a non-stream Accept header through unchanged")
+ void passesInboundAcceptThrough() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn("application/json");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "Accept")).isEqualTo("application/json");
+ }
+
+ @Test
+ @DisplayName("no Accept header set when inbound Accept is absent and SSE not requested")
+ void noAcceptWhenAbsentAndNotStreaming() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("blank inbound Accept is not forwarded when SSE not requested")
+ void blankAcceptNotForwarded() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("HTTP method and body publisher selection")
+ class MethodAndBody {
+
+ @Test
+ @DisplayName("GET sends an empty body and never reads the request input stream")
+ void getHasNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("GET");
+ assertThat(sent.bodyPublisher()).isPresent();
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ // GET/DELETE short-circuit before touching the body.
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ @DisplayName("DELETE sends an empty body and never reads the request input stream")
+ void deleteHasNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("DELETE", "/v1/item/9", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("DELETE");
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ @DisplayName("method name matching is case-insensitive for the no-body branch")
+ void lowercaseGetStillNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("get", "/v1/x", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualToIgnoringCase("get");
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ @DisplayName("lowercase delete also routes through the no-body branch")
+ void lowercaseDeleteNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("delete", "/v1/item/1", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ @DisplayName("POST streams the request input stream as an unknown-length body")
+ void postStreamsInputStream() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("application/json");
+ byte[] payload = "{\"x\":1}".getBytes(StandardCharsets.UTF_8);
+ when(request.getInputStream()).thenReturn(servletInputStream(payload));
+
+ service.forward("POST", "/v1/chat", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("POST");
+ assertThat(sent.bodyPublisher()).isPresent();
+ // ofInputStream publishes with an unknown content length (-1).
+ assertThat(sent.bodyPublisher().get().contentLength()).isEqualTo(-1L);
+ }
+
+ @Test
+ @DisplayName("PUT also streams the request body via the input-stream publisher")
+ void putStreamsInputStream() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn(null);
+ when(request.getInputStream())
+ .thenReturn(servletInputStream("raw".getBytes(StandardCharsets.UTF_8)));
+
+ service.forward("PUT", "/v1/item/3", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("PUT");
+ assertThat(sent.bodyPublisher().get().contentLength()).isEqualTo(-1L);
+ }
+
+ @Test
+ @DisplayName(
+ "the streamed body publisher lazily emits the exact request bytes when drained")
+ void streamedBodyContainsRequestBytes() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("application/json");
+ when(request.getInputStream())
+ .thenReturn(servletInputStream("hello-body".getBytes(StandardCharsets.UTF_8)));
+
+ service.forward("POST", "/v1/chat", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ // The supplier is lazy: getInputStream() is only invoked once the body is consumed.
+ String body = drainBody(sent.bodyPublisher().get());
+ assertThat(body).isEqualTo("hello-body");
+ }
+
+ @Test
+ @DisplayName(
+ "an IOException while opening the request stream surfaces as UncheckedIOException")
+ void inputStreamFailureBecomesUnchecked() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("application/json");
+ when(request.getInputStream()).thenThrow(new IOException("stream gone"));
+
+ service.forward("POST", "/v1/chat", request, false);
+
+ // The failure only triggers when the lazy supplier runs at body-drain time.
+ HttpRequest sent = captureSentRequest();
+ assertThatThrownBy(() -> drainBody(sent.bodyPublisher().get()))
+ .isInstanceOf(UncheckedIOException.class)
+ .hasRootCauseInstanceOf(IOException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("response propagation and send delegation")
+ class SendDelegation {
+
+ @Test
+ @DisplayName("returns exactly the response produced by the underlying client")
+ void returnsClientResponse() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ HttpResponse result = service.forward("GET", "/v1/x", request, false);
+
+ assertThat(result).isSameAs(response);
+ }
+
+ @Test
+ @DisplayName("an IOException from the client propagates to the caller")
+ void clientIoExceptionPropagates() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new IOException("connection refused"));
+
+ assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
+ .isInstanceOf(IOException.class)
+ .hasMessage("connection refused");
+ }
+
+ @Test
+ @DisplayName("an InterruptedException from the client propagates to the caller")
+ void clientInterruptedExceptionPropagates() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new InterruptedException("interrupted"));
+
+ assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
+ .isInstanceOf(InterruptedException.class);
+ // Clear the interrupt flag the thrown InterruptedException may have left.
+ Thread.interrupted();
+ }
+ }
+
+ // --- helpers ------------------------------------------------------------------------------
+
+ /** Drain a BodyPublisher to a UTF-8 string, propagating any error the supplier throws. */
+ private static String drainBody(HttpRequest.BodyPublisher publisher) {
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ java.util.concurrent.atomic.AtomicReference error =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ java.util.concurrent.Flow.Subscriber subscriber =
+ new java.util.concurrent.Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(java.util.concurrent.Flow.Subscription s) {
+ s.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(java.nio.ByteBuffer item) {
+ byte[] chunk = new byte[item.remaining()];
+ item.get(chunk);
+ out.write(chunk, 0, chunk.length);
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ error.set(t);
+ }
+
+ @Override
+ public void onComplete() {}
+ };
+ publisher.subscribe(subscriber);
+ Throwable t = error.get();
+ if (t instanceof RuntimeException re) {
+ throw re;
+ }
+ if (t != null) {
+ throw new RuntimeException(t);
+ }
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ /** Minimal ServletInputStream over a fixed byte array for streaming-body tests. */
+ private static ServletInputStream servletInputStream(byte[] data) {
+ ByteArrayInputStream delegate = new ByteArrayInputStream(data);
+ return new ServletInputStream() {
+ @Override
+ public int read() {
+ return delegate.read();
+ }
+
+ @Override
+ public boolean isFinished() {
+ return delegate.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(jakarta.servlet.ReadListener readListener) {
+ // no-op: synchronous reads only in tests
+ }
+ };
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateSessionServiceTest.java b/app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateSessionServiceTest.java
new file mode 100644
index 000000000..dd410cca5
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/ai/service/AiCreateSessionServiceTest.java
@@ -0,0 +1,783 @@
+package stirling.software.saas.ai.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import org.springframework.web.server.ResponseStatusException;
+
+import stirling.software.common.service.UserServiceInterface;
+import stirling.software.saas.ai.model.AiCreateSession;
+import stirling.software.saas.ai.model.AiCreateSessionStatus;
+import stirling.software.saas.ai.repository.AiCreateSessionRepository;
+import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
+
+/**
+ * Unit tests for {@link AiCreateSessionService}.
+ *
+ * The service is a thin persistence orchestrator over {@link AiCreateSessionRepository} plus a
+ * three-tier user-id resolution chain: {@code UserServiceInterface.getCurrentUsername()} ->
+ * Supabase id from the {@link SecurityContextHolder} authentication -> servlet session-scoped id ->
+ * the {@code "default_user"} fallback. Repository.save is stubbed to echo its argument so the
+ * field-mutation assertions can read back what the service set. SecurityContext and
+ * RequestContextHolder are reset after every test to keep the static thread-local state isolated.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AiCreateSessionServiceTest {
+
+ @Mock private AiCreateSessionRepository repository;
+ @Mock private UserServiceInterface userService;
+
+ private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
+ private static final String DEFAULT_USER_ID = "default_user";
+
+ @BeforeEach
+ void echoSave() {
+ // Persistence is a no-op for these unit tests; save() returns the same managed entity so
+ // mutation assertions can read it back.
+ when(repository.save(any(AiCreateSession.class))).thenAnswer(inv -> inv.getArgument(0));
+ }
+
+ @AfterEach
+ void clearStatics() {
+ SecurityContextHolder.clearContext();
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ /** Service with a present (but unstubbed-by-default) UserServiceInterface. */
+ private AiCreateSessionService serviceWithUserService() {
+ return new AiCreateSessionService(repository, Optional.of(userService));
+ }
+
+ /** Service with no UserServiceInterface bean wired (Optional.empty). */
+ private AiCreateSessionService serviceWithoutUserService() {
+ return new AiCreateSessionService(repository, Optional.empty());
+ }
+
+ /** Authenticate the SecurityContext with a Supabase-id-bearing JWT token. */
+ private static void authenticateJwt(String supabaseId) {
+ Map headers = new HashMap<>();
+ headers.put("alg", "RS256");
+ Map claims = new HashMap<>();
+ claims.put("sub", supabaseId);
+ claims.put("email", "user@example.com");
+ Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
+ EnhancedJwtAuthenticationToken auth =
+ new EnhancedJwtAuthenticationToken(
+ jwt,
+ List.of(new SimpleGrantedAuthority("ROLE_USER")),
+ "user@example.com",
+ supabaseId);
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+
+ /** Bind a servlet request (optionally with a live HttpSession) to the current thread. */
+ private static MockHttpServletRequest bindRequest(boolean withSession, String sessionId) {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ if (withSession) {
+ // (ServletContext, id) ctor fixes the session id so "session:" is deterministic.
+ request.setSession(new MockHttpSession(null, sessionId));
+ }
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ return request;
+ }
+
+ /** A persisted AiCreateSession owned by the given user. */
+ private static AiCreateSession existingSession(String sessionId, String userId) {
+ AiCreateSession session = new AiCreateSession();
+ session.setSessionId(sessionId);
+ session.setUserId(userId);
+ session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
+ return session;
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // resolveUserId() — three-tier precedence chain
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("resolveUserId precedence")
+ class ResolveUserId {
+
+ @Test
+ @DisplayName("UserServiceInterface username wins over everything else")
+ void userServiceUsernameWins() {
+ // Even with a JWT auth present, the username from the user service takes priority.
+ authenticateJwt(SUPABASE_ID);
+ when(userService.getCurrentUsername()).thenReturn("alice@corp.com");
+
+ assertThat(serviceWithUserService().resolveUserId()).isEqualTo("alice@corp.com");
+ }
+
+ @Test
+ @DisplayName("blank username is ignored and the chain falls through to the JWT id")
+ void blankUsernameFallsThroughToJwt() {
+ authenticateJwt(SUPABASE_ID);
+ when(userService.getCurrentUsername()).thenReturn(" ");
+
+ assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("anonymousUser username is ignored and the chain falls through")
+ void anonymousUsernameFallsThrough() {
+ authenticateJwt(SUPABASE_ID);
+ when(userService.getCurrentUsername()).thenReturn("anonymousUser");
+
+ assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("null username is ignored and the chain falls through")
+ void nullUsernameFallsThrough() {
+ authenticateJwt(SUPABASE_ID);
+ when(userService.getCurrentUsername()).thenReturn(null);
+
+ assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("a throwing user service is swallowed and the chain falls through")
+ void throwingUserServiceSwallowedAndFallsThrough() {
+ authenticateJwt(SUPABASE_ID);
+ when(userService.getCurrentUsername()).thenThrow(new RuntimeException("boom"));
+
+ assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("absent user service bean skips tier 1 and uses the JWT id")
+ void absentUserServiceUsesJwt() {
+ authenticateJwt(SUPABASE_ID);
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("unauthenticated 2-arg token (isAuthenticated=false) is skipped")
+ void unauthenticatedTokenSkipped() {
+ // The 2-arg UsernamePasswordAuthenticationToken ctor leaves isAuthenticated()=false,
+ // so the JWT branch is bypassed and we fall through to the default.
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken("bob", "creds"));
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
+ }
+
+ @Test
+ @DisplayName("authenticated principal id is used via the generic getName() fallback")
+ void authenticatedPrincipalNameUsed() {
+ // A non-JWT authenticated token: extractSupabaseId falls back to getName().
+ SecurityContextHolder.getContext()
+ .setAuthentication(
+ new UsernamePasswordAuthenticationToken(
+ "carol",
+ "creds",
+ List.of(new SimpleGrantedAuthority("ROLE_USER"))));
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("carol");
+ }
+
+ @Test
+ @DisplayName("authenticated 'anonymousUser' name is rejected, chain falls through")
+ void anonymousAuthNameRejected() {
+ SecurityContextHolder.getContext()
+ .setAuthentication(
+ new UsernamePasswordAuthenticationToken(
+ "anonymousUser",
+ "creds",
+ List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
+ }
+
+ @Test
+ @DisplayName("no auth + a live HttpSession yields a session-scoped id")
+ void sessionScopedIdWhenNoAuth() {
+ bindRequest(true, "sess-abc");
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("session:sess-abc");
+ }
+
+ @Test
+ @DisplayName("no auth + a request without a session falls through to default")
+ void noSessionFallsThroughToDefault() {
+ bindRequest(false, null);
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
+ }
+
+ @Test
+ @DisplayName("no user service, no auth, no request context -> default_user")
+ void defaultUserWhenNothingResolves() {
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
+ }
+
+ @Test
+ @DisplayName("JWT id is preferred over an available session-scoped id")
+ void jwtPreferredOverSession() {
+ authenticateJwt(SUPABASE_ID);
+ bindRequest(true, "sess-xyz");
+
+ assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // createSession
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("createSession")
+ class CreateSession {
+
+ @Test
+ @DisplayName("populates every field, generates a session id, and persists once")
+ void populatesAndSaves() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSessionService service = serviceWithUserService();
+
+ AiCreateSession out =
+ service.createSession(
+ "my prompt", "report", "tmpl-1", "\\documentclass{}", "preview");
+
+ assertThat(out.getUserId()).isEqualTo("owner");
+ assertThat(out.getDocType()).isEqualTo("report");
+ assertThat(out.getTemplateId()).isEqualTo("tmpl-1");
+ assertThat(out.getTemplateTex()).isEqualTo("\\documentclass{}");
+ assertThat(out.getPreviewTex()).isEqualTo("preview");
+ assertThat(out.getPromptInitial()).isEqualTo("my prompt");
+ assertThat(out.getPromptLatest()).isEqualTo("my prompt");
+ assertThat(out.isOutlineApproved()).isFalse();
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
+ // A random UUID session id was generated.
+ assertThat(out.getSessionId()).isNotBlank();
+ assertThat(UUID.fromString(out.getSessionId())).isNotNull();
+ verify(repository).save(out);
+ }
+
+ @Test
+ @DisplayName("two sessions get distinct generated ids")
+ void distinctSessionIds() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSessionService service = serviceWithUserService();
+
+ AiCreateSession a = service.createSession("p", null, null, null, null);
+ AiCreateSession b = service.createSession("p", null, null, null, null);
+
+ assertThat(a.getSessionId()).isNotEqualTo(b.getSessionId());
+ }
+
+ @Test
+ @DisplayName("uses default_user when nothing else resolves the identity")
+ void usesDefaultUser() {
+ AiCreateSession out =
+ serviceWithoutUserService().createSession("p", "doc", "t", "tex", "prev");
+
+ assertThat(out.getUserId()).isEqualTo(DEFAULT_USER_ID);
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // getSession / getSessionForCurrentUser
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("getSession / getSessionForCurrentUser")
+ class GetSession {
+
+ @Test
+ @DisplayName("getSession returns the persisted row")
+ void getSessionReturnsRow() {
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ assertThat(serviceWithoutUserService().getSession("s1")).isSameAs(row);
+ }
+
+ @Test
+ @DisplayName("getSession throws 404 when the row is missing")
+ void getSessionMissingThrows404() {
+ when(repository.findById("nope")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> serviceWithoutUserService().getSession("nope"))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ ex ->
+ assertThat(((ResponseStatusException) ex).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+ }
+
+ @Test
+ @DisplayName("getSessionForCurrentUser returns the row when the owner matches")
+ void ownerMatchReturnsRow() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ assertThat(serviceWithUserService().getSessionForCurrentUser("s1")).isSameAs(row);
+ }
+
+ @Test
+ @DisplayName("getSessionForCurrentUser hides another user's session behind a 404")
+ void foreignOwnerThrows404() {
+ when(userService.getCurrentUsername()).thenReturn("intruder");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ assertThatThrownBy(() -> serviceWithUserService().getSessionForCurrentUser("s1"))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ ex ->
+ assertThat(((ResponseStatusException) ex).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // updateOutline
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("updateOutline")
+ class UpdateOutline {
+
+ @Test
+ @DisplayName("sets outline text, filename, constraints, approval flag and APPROVED status")
+ void fullUpdate() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out =
+ serviceWithUserService()
+ .updateOutline("s1", "the outline", "outline.tex", "be brief");
+
+ assertThat(out.getOutlineText()).isEqualTo("the outline");
+ assertThat(out.getOutlineFilename()).isEqualTo("outline.tex");
+ assertThat(out.getOutlineConstraints()).isEqualTo("be brief");
+ assertThat(out.isOutlineApproved()).isTrue();
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("blank filename is not applied; null constraints are left untouched")
+ void blankFilenameAndNullConstraintsIgnored() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setOutlineFilename("keep.tex");
+ row.setOutlineConstraints("keep-constraints");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out = serviceWithUserService().updateOutline("s1", "txt", " ", null);
+
+ assertThat(out.getOutlineFilename()).isEqualTo("keep.tex");
+ assertThat(out.getOutlineConstraints()).isEqualTo("keep-constraints");
+ // Still approved + status flipped even with skipped optional fields.
+ assertThat(out.isOutlineApproved()).isTrue();
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
+ }
+
+ @Test
+ @DisplayName("empty-string constraints ARE applied (only null is skipped)")
+ void emptyConstraintsApplied() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setOutlineConstraints("old");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out = serviceWithUserService().updateOutline("s1", "t", "f", "");
+
+ assertThat(out.getOutlineConstraints()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("a foreign session 404s before any mutation or save")
+ void foreignSessionBlocked() {
+ when(userService.getCurrentUsername()).thenReturn("intruder");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ assertThatThrownBy(() -> serviceWithUserService().updateOutline("s1", "t", "f", "c"))
+ .isInstanceOf(ResponseStatusException.class);
+ assertThat(row.getOutlineText()).isNull();
+ verify(repository, never()).save(any());
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // updateDraftSections
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("updateDraftSections stores sections and flips status to DRAFT_READY")
+ void updateDraftSections() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out = serviceWithUserService().updateDraftSections("s1", "section json");
+
+ assertThat(out.getDraftSections()).isEqualTo("section json");
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
+ verify(repository).save(row);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // updateTemplate
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("updateTemplate")
+ class UpdateTemplate {
+
+ @Test
+ @DisplayName("updates docType and templateId when both are non-blank")
+ void updatesBoth() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setDocType("old-doc");
+ row.setTemplateId("old-tmpl");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out =
+ serviceWithUserService().updateTemplate("s1", "new-doc", "new-tmpl");
+
+ assertThat(out.getDocType()).isEqualTo("new-doc");
+ assertThat(out.getTemplateId()).isEqualTo("new-tmpl");
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("null/blank inputs leave the existing template untouched")
+ void blankInputsKeepExisting() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setDocType("old-doc");
+ row.setTemplateId("old-tmpl");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out = serviceWithUserService().updateTemplate("s1", null, " ");
+
+ assertThat(out.getDocType()).isEqualTo("old-doc");
+ assertThat(out.getTemplateId()).isEqualTo("old-tmpl");
+ // Still persists (no-op save) — the method always saves.
+ verify(repository).save(row);
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // reprompt
+ // -------------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("reprompt resets all derived artifacts and re-enters OUTLINE_PENDING")
+ void reprompt() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setPromptLatest("old prompt");
+ row.setOutlineText("old outline");
+ row.setOutlineFilename("old.tex");
+ row.setOutlineApproved(true);
+ row.setOutlineConstraints("old constraints");
+ row.setDraftSections("old draft");
+ row.setPolishedLatex("old latex");
+ row.setPdfUrl("https://old/url");
+ row.setStatus(AiCreateSessionStatus.POLISHED_READY);
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out = serviceWithUserService().reprompt("s1", "fresh prompt");
+
+ assertThat(out.getPromptLatest()).isEqualTo("fresh prompt");
+ assertThat(out.getOutlineText()).isNull();
+ assertThat(out.getOutlineFilename()).isNull();
+ assertThat(out.isOutlineApproved()).isFalse();
+ assertThat(out.getOutlineConstraints()).isNull();
+ assertThat(out.getDraftSections()).isNull();
+ assertThat(out.getPolishedLatex()).isNull();
+ assertThat(out.getPdfUrl()).isNull();
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
+ verify(repository).save(row);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // deleteSessionForCurrentUser
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("deleteSessionForCurrentUser")
+ class DeleteSession {
+
+ @Test
+ @DisplayName("deletes the owner's session")
+ void deletesOwnerSession() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ serviceWithUserService().deleteSessionForCurrentUser("s1");
+
+ verify(repository).delete(row);
+ }
+
+ @Test
+ @DisplayName("a foreign session 404s and is never deleted")
+ void foreignSessionNotDeleted() {
+ when(userService.getCurrentUsername()).thenReturn("intruder");
+ AiCreateSession row = existingSession("s1", "owner");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ assertThatThrownBy(() -> serviceWithUserService().deleteSessionForCurrentUser("s1"))
+ .isInstanceOf(ResponseStatusException.class);
+ verify(repository, never()).delete(any());
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // applyInternalUpdate — null-coalescing partial update, no ownership check
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("applyInternalUpdate")
+ class ApplyInternalUpdate {
+
+ @Test
+ @DisplayName("applies every non-null field including outlineApproved=false")
+ void appliesAllFields() {
+ // No ownership check on the internal path: it uses getSession, not the per-user guard.
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setOutlineApproved(true);
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out =
+ serviceWithoutUserService()
+ .applyInternalUpdate(
+ "s1",
+ "outline",
+ "o.tex",
+ Boolean.FALSE,
+ "constraints",
+ "draft",
+ "latex",
+ "https://pdf/url",
+ "doc",
+ "tmpl",
+ AiCreateSessionStatus.POLISHED_READY);
+
+ assertThat(out.getOutlineText()).isEqualTo("outline");
+ assertThat(out.getOutlineFilename()).isEqualTo("o.tex");
+ // Boolean.FALSE is non-null so it IS applied, flipping the prior true.
+ assertThat(out.isOutlineApproved()).isFalse();
+ assertThat(out.getOutlineConstraints()).isEqualTo("constraints");
+ assertThat(out.getDraftSections()).isEqualTo("draft");
+ assertThat(out.getPolishedLatex()).isEqualTo("latex");
+ assertThat(out.getPdfUrl()).isEqualTo("https://pdf/url");
+ assertThat(out.getDocType()).isEqualTo("doc");
+ assertThat(out.getTemplateId()).isEqualTo("tmpl");
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.POLISHED_READY);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("all-null arguments leave the row untouched but still persist")
+ void allNullLeavesUntouched() {
+ AiCreateSession row = existingSession("s1", "owner");
+ row.setOutlineText("keep-outline");
+ row.setDocType("keep-doc");
+ row.setStatus(AiCreateSessionStatus.DRAFT_READY);
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out =
+ serviceWithoutUserService()
+ .applyInternalUpdate(
+ "s1", null, null, null, null, null, null, null, null, null,
+ null);
+
+ assertThat(out.getOutlineText()).isEqualTo("keep-outline");
+ assertThat(out.getDocType()).isEqualTo("keep-doc");
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("internal path 404s when the session does not exist")
+ void missingSession404() {
+ when(repository.findById("ghost")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(
+ () ->
+ serviceWithoutUserService()
+ .applyInternalUpdate(
+ "ghost", "x", null, null, null, null, null,
+ null, null, null, null))
+ .isInstanceOf(ResponseStatusException.class);
+ }
+
+ @Test
+ @DisplayName("internal path ignores ownership — updates a row owned by another user")
+ void ignoresOwnership() {
+ // applyInternalUpdate uses getSession (not getSessionForCurrentUser); current identity
+ // is irrelevant. Confirm a non-matching identity still updates the row.
+ authenticateJwt(SUPABASE_ID);
+ AiCreateSession row = existingSession("s1", "someone-else");
+ when(repository.findById("s1")).thenReturn(Optional.of(row));
+
+ AiCreateSession out =
+ serviceWithoutUserService()
+ .applyInternalUpdate(
+ "s1",
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "https://done/pdf",
+ null,
+ null,
+ AiCreateSessionStatus.SAVED);
+
+ assertThat(out.getPdfUrl()).isEqualTo("https://done/pdf");
+ assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.SAVED);
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // list* — delegation to the right repository finder for the resolved user
+ // -------------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("listing methods")
+ class Listing {
+
+ @Test
+ @DisplayName("no-arg list delegates to findByUserIdOrderByUpdatedAtDesc(userId)")
+ void listNoArg() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ List expected = List.of(existingSession("s1", "owner"));
+ when(repository.findByUserIdOrderByUpdatedAtDesc("owner")).thenReturn(expected);
+
+ assertThat(serviceWithUserService().listSessionsForCurrentUser()).isSameAs(expected);
+ }
+
+ @Test
+ @DisplayName("paged list delegates with the pageable for the resolved user")
+ void listPaged() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ Pageable pageable = PageRequest.of(0, 20);
+ List expected = List.of(existingSession("s1", "owner"));
+ when(repository.findByUserIdOrderByUpdatedAtDesc("owner", pageable))
+ .thenReturn(expected);
+
+ assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable))
+ .isSameAs(expected);
+ }
+
+ @Test
+ @DisplayName("includeDrafts=true returns the all-sessions finder")
+ void listIncludeDraftsTrue() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ Pageable pageable = PageRequest.of(0, 10);
+ List expected = List.of(existingSession("s1", "owner"));
+ when(repository.findByUserIdOrderByUpdatedAtDesc("owner", pageable))
+ .thenReturn(expected);
+
+ assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable, true))
+ .isSameAs(expected);
+ verify(repository, never())
+ .findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(any(), any());
+ }
+
+ @Test
+ @DisplayName("includeDrafts=false returns only sessions with a non-null pdfUrl")
+ void listIncludeDraftsFalse() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ Pageable pageable = PageRequest.of(0, 10);
+ List expected = List.of(existingSession("s1", "owner"));
+ when(repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc("owner", pageable))
+ .thenReturn(expected);
+
+ assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable, false))
+ .isSameAs(expected);
+ verify(repository, never())
+ .findByUserIdOrderByUpdatedAtDesc(eq("owner"), any(Pageable.class));
+ }
+
+ @Test
+ @DisplayName("summary list includeDrafts=true uses the all-summaries projection finder")
+ void summariesIncludeDraftsTrue() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ Pageable pageable = PageRequest.of(0, 10);
+ List expected = List.of();
+ when(repository.findSummariesByUserIdOrderByUpdatedAtDesc("owner", pageable))
+ .thenReturn(expected);
+
+ assertThat(serviceWithUserService().listSessionSummariesForCurrentUser(pageable, true))
+ .isSameAs(expected);
+ verify(repository, never())
+ .findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(any(), any());
+ }
+
+ @Test
+ @DisplayName("summary list includeDrafts=false uses the pdf-only summaries finder")
+ void summariesIncludeDraftsFalse() {
+ when(userService.getCurrentUsername()).thenReturn("owner");
+ Pageable pageable = PageRequest.of(0, 10);
+ List expected = List.of();
+ when(repository.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
+ "owner", pageable))
+ .thenReturn(expected);
+
+ assertThat(serviceWithUserService().listSessionSummariesForCurrentUser(pageable, false))
+ .isSameAs(expected);
+ verify(repository, never()).findSummariesByUserIdOrderByUpdatedAtDesc(any(), any());
+ }
+
+ @Test
+ @DisplayName("listing for an unidentified caller queries the default_user partition")
+ void listForDefaultUser() {
+ List expected = List.of();
+ when(repository.findByUserIdOrderByUpdatedAtDesc(DEFAULT_USER_ID)).thenReturn(expected);
+
+ assertThat(serviceWithoutUserService().listSessionsForCurrentUser()).isSameAs(expected);
+ ArgumentCaptor userIdCaptor = ArgumentCaptor.forClass(String.class);
+ verify(repository).findByUserIdOrderByUpdatedAtDesc(userIdCaptor.capture());
+ assertThat(userIdCaptor.getValue()).isEqualTo(DEFAULT_USER_ID);
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/ai/service/AiProxyServiceTest.java b/app/saas/src/test/java/stirling/software/saas/ai/service/AiProxyServiceTest.java
new file mode 100644
index 000000000..817958bac
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/ai/service/AiProxyServiceTest.java
@@ -0,0 +1,678 @@
+package stirling.software.saas.ai.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.Part;
+
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.service.UserService;
+
+/**
+ * Unit tests for {@link AiProxyService}.
+ *
+ * The service forwards HTTP requests to an AI backend. It constructs its own {@link HttpClient}
+ * internally (no constructor injection), so each test swaps in a mocked client via {@link
+ * ReflectionTestUtils} and captures the outgoing {@link HttpRequest} to assert on URL, method and
+ * headers. All collaborators ({@link HttpServletRequest}, {@link UserService}, {@link
+ * UserRepository}) are mocked; no Spring context, DB or real network is involved.
+ *
+ *
Header semantics under test: Authorization is forwarded when present/non-blank; X-API-KEY is
+ * taken from the request header first and otherwise resolved from the authenticated user; Accept is
+ * overridden to {@code text/event-stream} when SSE is requested; the target URL is assembled from
+ * the configured base URL, the path and the query string.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AiProxyServiceTest {
+
+ private static final String BASE_URL = "http://ai-backend:5001";
+
+ @Mock private UserRepository userRepository;
+ @Mock private UserService userService;
+ @Mock private HttpServletRequest request;
+ @Mock private HttpClient httpClient;
+
+ @SuppressWarnings("unchecked")
+ private final HttpResponse response =
+ (HttpResponse) org.mockito.Mockito.mock(HttpResponse.class);
+
+ private AiProxyService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new AiProxyService(BASE_URL, userRepository, userService);
+ // Swap the internally-built client for our mock so no real network call happens.
+ ReflectionTestUtils.setField(service, "httpClient", httpClient);
+ // Default: the mocked client returns our stub response for any send().
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenReturn(response);
+ }
+
+ /** Capture the single HttpRequest the service hands to the client. */
+ private HttpRequest captureSentRequest() throws Exception {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
+ return captor.getValue();
+ }
+
+ private static String header(HttpRequest req, String name) {
+ return req.headers().firstValue(name).orElse(null);
+ }
+
+ @Nested
+ @DisplayName("target URL assembly")
+ class UrlAssembly {
+
+ @Test
+ @DisplayName("joins base + leading-slash path + query string")
+ void joinsBasePathAndQuery() throws Exception {
+ when(request.getQueryString()).thenReturn("model=foo&n=2");
+
+ service.forward("GET", "/v1/chat", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/chat?model=foo&n=2"));
+ }
+
+ @Test
+ @DisplayName("prepends a slash when the path lacks one")
+ void prependsMissingSlash() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("GET", "v1/health", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/health"));
+ }
+
+ @Test
+ @DisplayName("blank query string is ignored")
+ void blankQueryIgnored() throws Exception {
+ when(request.getQueryString()).thenReturn(" ");
+
+ service.forward("GET", "/v1/ping", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
+ }
+
+ @Test
+ @DisplayName("trailing slash on the configured base URL is trimmed")
+ void trimsTrailingSlashOnBase() throws Exception {
+ AiProxyService svc =
+ new AiProxyService("http://ai-backend:5001/", userRepository, userService);
+ ReflectionTestUtils.setField(svc, "httpClient", httpClient);
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/x"));
+ }
+
+ @Test
+ @DisplayName("surrounding whitespace on the base URL is trimmed")
+ void trimsWhitespaceOnBase() throws Exception {
+ AiProxyService svc =
+ new AiProxyService(" http://ai-backend:5001 ", userRepository, userService);
+ ReflectionTestUtils.setField(svc, "httpClient", httpClient);
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/y", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://ai-backend:5001/v1/y"));
+ }
+
+ @Test
+ @DisplayName("blank base URL falls back to the localhost default")
+ void blankBaseUrlFallsBackToDefault() throws Exception {
+ AiProxyService svc = new AiProxyService(" ", userRepository, userService);
+ ReflectionTestUtils.setField(svc, "httpClient", httpClient);
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/z", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://localhost:5001/v1/z"));
+ }
+
+ @Test
+ @DisplayName("null base URL falls back to the localhost default")
+ void nullBaseUrlFallsBackToDefault() throws Exception {
+ AiProxyService svc = new AiProxyService(null, userRepository, userService);
+ ReflectionTestUtils.setField(svc, "httpClient", httpClient);
+ when(request.getQueryString()).thenReturn(null);
+
+ svc.forward("GET", "/v1/q", request, false);
+
+ assertThat(captureSentRequest().uri())
+ .isEqualTo(URI.create("http://localhost:5001/v1/q"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Authorization header forwarding")
+ class AuthorizationForwarding {
+
+ @Test
+ @DisplayName("forwards a present Authorization header verbatim")
+ void forwardsPresentAuth() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn("Bearer abc.def");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "Authorization")).isEqualTo("Bearer abc.def");
+ }
+
+ @Test
+ @DisplayName("omits the header when Authorization is null")
+ void omitsWhenNull() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("omits the header when Authorization is blank")
+ void omitsWhenBlank() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("X-API-KEY resolution")
+ class ApiKeyResolution {
+
+ @Test
+ @DisplayName("uses the X-API-KEY header from the request when present (no user lookup)")
+ void usesRequestHeaderAndSkipsUserLookup() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn("req-key-123");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("req-key-123");
+ // Header short-circuits the authenticated-user fallback entirely.
+ verifyNoInteractions(userService);
+ }
+
+ @Test
+ @DisplayName("falls back to the authenticated user's API key when the header is absent")
+ void fallsBackToUserApiKey() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("alice");
+ when(userService.getApiKeyForUser("alice")).thenReturn("user-key-xyz");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("user-key-xyz");
+ verify(userService).getApiKeyForUser("alice");
+ }
+
+ @Test
+ @DisplayName("falls back to the user key when the header is blank")
+ void blankHeaderTriggersFallback() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(" ");
+ when(userService.getCurrentUsername()).thenReturn("bob");
+ when(userService.getApiKeyForUser("bob")).thenReturn("bob-key");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("bob-key");
+ }
+
+ @Test
+ @DisplayName("no X-API-KEY header set when there is no authenticated user")
+ void noUser_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ // Username was null/blank, so the key lookup is never attempted.
+ verify(userService, never()).getApiKeyForUser(any());
+ }
+
+ @Test
+ @DisplayName("blank username from the security context yields no header")
+ void blankUsername_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ verify(userService, never()).getApiKeyForUser(any());
+ }
+
+ @Test
+ @DisplayName("a resolved-but-blank user key is not forwarded")
+ void blankResolvedKey_noHeader() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("carol");
+ when(userService.getApiKeyForUser("carol")).thenReturn("");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("an exception while resolving the user key is swallowed; no header forwarded")
+ void userKeyLookupThrows_isSwallowed() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("X-API-KEY")).thenReturn(null);
+ when(userService.getCurrentUsername()).thenReturn("dave");
+ when(userService.getApiKeyForUser("dave"))
+ .thenThrow(new RuntimeException("key store offline"));
+
+ // Must not propagate: extractUserApiKey() catches and returns null.
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("Accept header handling")
+ class AcceptHandling {
+
+ @Test
+ @DisplayName("acceptEventStream overrides any inbound Accept with text/event-stream")
+ void eventStreamOverrides() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn("application/json");
+
+ service.forward("GET", "/v1/stream", request, true);
+
+ assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
+ }
+
+ @Test
+ @DisplayName("event stream is requested even with no inbound Accept header")
+ void eventStreamWithoutInboundAccept() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn(null);
+
+ service.forward("GET", "/v1/stream", request, true);
+
+ assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
+ }
+
+ @Test
+ @DisplayName("passes a non-stream Accept header through unchanged")
+ void passesInboundAcceptThrough() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn("application/json");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(header(captureSentRequest(), "Accept")).isEqualTo("application/json");
+ }
+
+ @Test
+ @DisplayName("no Accept header set when inbound Accept is absent and SSE not requested")
+ void noAcceptWhenAbsentAndNotStreaming() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("blank inbound Accept is not forwarded when SSE not requested")
+ void blankAcceptNotForwarded() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getHeader("Accept")).thenReturn(" ");
+
+ service.forward("GET", "/v1/x", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("HTTP method and body publisher selection")
+ class MethodAndBody {
+
+ @Test
+ @DisplayName("GET sends no body and never reads the request input stream")
+ void getHasNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("GET", "/v1/x", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("GET");
+ assertThat(sent.bodyPublisher()).isPresent();
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ // GET/DELETE short-circuit before touching the body.
+ verify(request, never()).getInputStream();
+ verify(request, never()).getParts();
+ }
+
+ @Test
+ @DisplayName("DELETE sends no body and never reads the request input stream")
+ void deleteHasNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("DELETE", "/v1/item/9", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("DELETE");
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ @DisplayName("method name matching is case-insensitive for the no-body branch")
+ void lowercaseGetStillNoBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ service.forward("get", "/v1/x", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ // Lowercase still routes through the GET/DELETE no-body branch.
+ assertThat(sent.method()).isEqualToIgnoringCase("get");
+ assertThat(sent.bodyPublisher().get().contentLength()).isZero();
+ verify(request, never()).getInputStream();
+ }
+
+ @Test
+ @DisplayName("POST with a plain content type streams the request input stream as the body")
+ void postStreamsInputStream() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("application/json");
+ ServletInputStream sis =
+ servletInputStream("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
+ when(request.getInputStream()).thenReturn(sis);
+
+ service.forward("POST", "/v1/chat", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ assertThat(sent.method()).isEqualTo("POST");
+ // ofInputStream publishes with an unknown length (-1).
+ assertThat(sent.bodyPublisher()).isPresent();
+ // Inbound Content-Type is propagated since the body publisher provides none.
+ assertThat(header(sent, "Content-Type")).isEqualTo("application/json");
+ }
+
+ @Test
+ @DisplayName("POST with no inbound content type sets no Content-Type header")
+ void postWithoutContentType() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn(null);
+ when(request.getInputStream())
+ .thenReturn(servletInputStream("raw".getBytes(StandardCharsets.UTF_8)));
+
+ service.forward("POST", "/v1/chat", request, false);
+
+ assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("multipart/form-data re-encoding")
+ class Multipart {
+
+ @Test
+ @DisplayName("re-encodes parts and sets a generated multipart boundary Content-Type")
+ void reencodesMultipartBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("multipart/form-data; boundary=inbound");
+
+ Part field = textPart("prompt", "hello world");
+ Part file = filePart("file", "doc.pdf", "application/pdf", "PDF-BYTES");
+ when(request.getParts()).thenReturn(List.of(field, file));
+
+ service.forward("POST", "/v1/upload", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ String contentType = header(sent, "Content-Type");
+ assertThat(contentType).startsWith("multipart/form-data; boundary=----spdf-");
+ // A fresh boundary is generated rather than reusing the inbound one.
+ assertThat(contentType).doesNotContain("inbound");
+ // Body has a known length (ofByteArray), unlike the streamed-input branch.
+ assertThat(sent.bodyPublisher()).isPresent();
+ assertThat(sent.bodyPublisher().get().contentLength()).isPositive();
+ }
+
+ @Test
+ @DisplayName("the generated boundary in the header matches the one used in the body bytes")
+ void boundaryHeaderMatchesBody() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("multipart/form-data");
+ Part textPart = textPart("k", "v");
+ when(request.getParts()).thenReturn(List.of(textPart));
+
+ service.forward("POST", "/v1/upload", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ String contentType = header(sent, "Content-Type");
+ String boundary =
+ contentType.substring(contentType.indexOf("boundary=") + "boundary=".length());
+
+ String body = drainBody(sent.bodyPublisher().get());
+ assertThat(body).contains("--" + boundary);
+ assertThat(body).contains("--" + boundary + "--");
+ assertThat(body).contains("Content-Disposition: form-data; name=\"k\"").contains("v");
+ }
+
+ @Test
+ @DisplayName("a file part renders a filename in its Content-Disposition")
+ void filePartRendersFilename() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("multipart/form-data");
+ Part filePart = filePart("file", "a.pdf", "application/pdf", "DATA");
+ when(request.getParts()).thenReturn(List.of(filePart));
+
+ service.forward("POST", "/v1/upload", request, false);
+
+ String body = drainBody(captureSentRequest().bodyPublisher().get());
+ assertThat(body)
+ .contains("Content-Disposition: form-data; name=\"file\"; filename=\"a.pdf\"")
+ .contains("Content-Type: application/pdf")
+ .contains("DATA");
+ }
+
+ @Test
+ @DisplayName("an empty parts collection still produces a valid closing boundary")
+ void emptyPartsClosesBoundary() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("multipart/form-data");
+ when(request.getParts()).thenReturn(List.of());
+
+ service.forward("POST", "/v1/upload", request, false);
+
+ HttpRequest sent = captureSentRequest();
+ String contentType = header(sent, "Content-Type");
+ String boundary =
+ contentType.substring(contentType.indexOf("boundary=") + "boundary=".length());
+ // Closing delimiter line + the trailing empty writeLine each append CRLF.
+ assertThat(drainBody(sent.bodyPublisher().get()))
+ .isEqualTo("--" + boundary + "--\r\n\r\n");
+ }
+
+ @Test
+ @DisplayName("a getParts() failure is surfaced as IOException")
+ void getPartsFailureBecomesIoException() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(request.getContentType()).thenReturn("multipart/form-data");
+ when(request.getParts())
+ .thenThrow(new jakarta.servlet.ServletException("bad multipart"));
+
+ assertThatThrownBy(() -> service.forward("POST", "/v1/upload", request, false))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Failed to proxy multipart request");
+ // Failed before reaching the client: send() is never invoked.
+ verify(httpClient, never())
+ .send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("response propagation and send delegation")
+ class SendDelegation {
+
+ @Test
+ @DisplayName("returns exactly the response produced by the underlying client")
+ void returnsClientResponse() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+
+ HttpResponse result = service.forward("GET", "/v1/x", request, false);
+
+ assertThat(result).isSameAs(response);
+ }
+
+ @Test
+ @DisplayName("an IOException from the client propagates to the caller")
+ void clientIoExceptionPropagates() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new IOException("connection refused"));
+
+ assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
+ .isInstanceOf(IOException.class)
+ .hasMessage("connection refused");
+ }
+
+ @Test
+ @DisplayName("an InterruptedException from the client propagates to the caller")
+ void clientInterruptedExceptionPropagates() throws Exception {
+ when(request.getQueryString()).thenReturn(null);
+ when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new InterruptedException("interrupted"));
+
+ assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
+ .isInstanceOf(InterruptedException.class);
+ // Clear the interrupt flag the thrown InterruptedException may have left.
+ Thread.interrupted();
+ }
+ }
+
+ // --- helpers ------------------------------------------------------------------------------
+
+ private static Part textPart(String name, String value) throws IOException {
+ Part p = org.mockito.Mockito.mock(Part.class);
+ when(p.getName()).thenReturn(name);
+ when(p.getSubmittedFileName()).thenReturn(null);
+ when(p.getContentType()).thenReturn(null);
+ when(p.getInputStream())
+ .thenReturn(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
+ return p;
+ }
+
+ private static Part filePart(String name, String filename, String contentType, String value)
+ throws IOException {
+ Part p = org.mockito.Mockito.mock(Part.class);
+ when(p.getName()).thenReturn(name);
+ when(p.getSubmittedFileName()).thenReturn(filename);
+ when(p.getContentType()).thenReturn(contentType);
+ when(p.getInputStream())
+ .thenReturn(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
+ return p;
+ }
+
+ private static String drainBody(HttpRequest.BodyPublisher publisher) {
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ java.util.concurrent.Flow.Subscriber subscriber =
+ new java.util.concurrent.Flow.Subscriber<>() {
+ @Override
+ public void onSubscribe(java.util.concurrent.Flow.Subscription s) {
+ s.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(java.nio.ByteBuffer item) {
+ byte[] chunk = new byte[item.remaining()];
+ item.get(chunk);
+ out.write(chunk, 0, chunk.length);
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ throw new RuntimeException(t);
+ }
+
+ @Override
+ public void onComplete() {}
+ };
+ publisher.subscribe(subscriber);
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ /** Minimal ServletInputStream over a fixed byte array for streaming-body tests. */
+ private static ServletInputStream servletInputStream(byte[] data) {
+ ByteArrayInputStream delegate = new ByteArrayInputStream(data);
+ return new ServletInputStream() {
+ @Override
+ public int read() {
+ return delegate.read();
+ }
+
+ @Override
+ public boolean isFinished() {
+ return delegate.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(jakarta.servlet.ReadListener readListener) {
+ // no-op: synchronous reads only in tests
+ }
+ };
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java b/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java
new file mode 100644
index 000000000..ea8721a4c
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java
@@ -0,0 +1,1355 @@
+package stirling.software.saas.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.transaction.TransactionStatus;
+import org.springframework.transaction.interceptor.TransactionAspectSupport;
+
+import stirling.software.common.model.enumeration.InvitationStatus;
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.proprietary.security.repository.TeamRepository;
+import stirling.software.proprietary.security.service.TeamService;
+import stirling.software.proprietary.security.service.UserService;
+import stirling.software.saas.controller.SaasTeamController.InviteUserRequest;
+import stirling.software.saas.controller.SaasTeamController.RenameTeamRequest;
+import stirling.software.saas.controller.SaasTeamController.UpdateSeatsRequest;
+import stirling.software.saas.model.TeamInvitation;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.repository.TeamInvitationRepository;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.security.TeamSecurityExpressions;
+import stirling.software.saas.service.SaasTeamExtensionService;
+import stirling.software.saas.service.SaasTeamService;
+
+/**
+ * Pure-Mockito unit tests for {@link SaasTeamController}.
+ *
+ * Each handler is invoked directly with mocked collaborators and the returned {@link
+ * ResponseEntity} (status + body) is asserted, alongside repository/service interaction
+ * verification. The controller uses {@code @RequiredArgsConstructor}, so {@link InjectMocks} wires
+ * the mocks by type into the field-injection constructor.
+ *
+ *
The {@code getCurrentUser()} helper resolves the principal via {@code
+ * userService.getCurrentUsername()} then {@code userService.findByUsername(...)}; tests that reach
+ * a handler body stub that pair. Several handlers are {@code @Transactional} and call {@link
+ * TransactionAspectSupport#currentTransactionStatus()} on their error paths, which requires a
+ * thread-bound transaction info; {@code TransactionSupport} installs a no-op one and clears it.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class SaasTeamControllerTest {
+
+ @Mock private TeamRepository teamRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private TeamService teamService;
+ @Mock private SaasTeamService saasTeamService;
+ @Mock private SaasTeamExtensionService saasTeamExtensionService;
+ @Mock private TeamMembershipRepository membershipRepository;
+ @Mock private TeamInvitationRepository invitationRepository;
+ @Mock private UserService userService;
+ @Mock private TeamSecurityExpressions teamSecurityExpressions;
+
+ @InjectMocks private SaasTeamController controller;
+
+ private static final String CURRENT_USERNAME = "alice";
+ private static final String CURRENT_EMAIL = "alice@example.com";
+
+ private User currentUser;
+
+ @BeforeEach
+ void setUp() {
+ currentUser = user(7L, CURRENT_USERNAME, CURRENT_EMAIL);
+ }
+
+ // ===== helpers =====
+
+ private static User user(Long id, String username, String email) {
+ User u = new User();
+ u.setId(id);
+ u.setUsername(username);
+ u.setEmail(email);
+ return u;
+ }
+
+ private static Team team(Long id, String name) {
+ Team t = new Team();
+ t.setId(id);
+ t.setName(name);
+ return t;
+ }
+
+ private TeamInvitation invitation(
+ Long id, Team team, User inviter, String inviteeEmail, InvitationStatus status) {
+ TeamInvitation inv = new TeamInvitation();
+ inv.setInvitationId(id);
+ inv.setTeam(team);
+ inv.setInviter(inviter);
+ inv.setInviteeEmail(inviteeEmail);
+ inv.setStatus(status);
+ inv.setInvitationToken("tok-" + id);
+ inv.setExpiresAt(LocalDateTime.now().plusDays(3));
+ return inv;
+ }
+
+ private TeamMembership membership(Team team, User member, TeamRole role) {
+ TeamMembership m = new TeamMembership();
+ m.setTeam(team);
+ m.setUser(member);
+ m.setRole(role);
+ m.setAcceptedAt(LocalDateTime.now());
+ return m;
+ }
+
+ /** Make {@code getCurrentUser()} resolve to {@link #currentUser}. */
+ private void stubCurrentUser() {
+ when(userService.getCurrentUsername()).thenReturn(CURRENT_USERNAME);
+ when(userService.findByUsername(CURRENT_USERNAME)).thenReturn(Optional.of(currentUser));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map body(ResponseEntity> response) {
+ return (Map) response.getBody();
+ }
+
+ @Nested
+ @DisplayName("inviteUser")
+ class InviteUser {
+
+ private InviteUserRequest request(Long teamId, String email) {
+ InviteUserRequest r = new InviteUserRequest();
+ r.setTeamId(teamId);
+ r.setEmail(email);
+ return r;
+ }
+
+ @Test
+ @DisplayName("happy path returns 200 with the invitation DTO")
+ void happyPath() {
+ stubCurrentUser();
+ when(teamSecurityExpressions.isTeamLeader(10L)).thenReturn(true);
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(99L, team, currentUser, "bob@example.com", InvitationStatus.PENDING);
+ when(saasTeamService.inviteUserToTeam(10L, "bob@example.com", currentUser))
+ .thenReturn(inv);
+
+ ResponseEntity> response = controller.inviteUser(request(10L, "bob@example.com"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ SaasTeamController.InvitationDTO dto =
+ (SaasTeamController.InvitationDTO) response.getBody();
+ assertThat(dto.getInvitationId()).isEqualTo(99L);
+ assertThat(dto.getTeamName()).isEqualTo("Acme");
+ assertThat(dto.getInviteeEmail()).isEqualTo("bob@example.com");
+ assertThat(dto.getInviterEmail()).isEqualTo(CURRENT_EMAIL);
+ assertThat(dto.getStatus()).isEqualTo("PENDING");
+ }
+
+ @Test
+ @DisplayName("non-leader is rejected with 403 before any service call")
+ void nonLeaderForbidden() {
+ stubCurrentUser();
+ when(teamSecurityExpressions.isTeamLeader(10L)).thenReturn(false);
+
+ ResponseEntity> response = controller.inviteUser(request(10L, "bob@example.com"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ assertThat(body(response))
+ .containsEntry("error", "Only team leaders can invite members");
+ verify(saasTeamService, never()).inviteUserToTeam(anyLong(), anyString(), any());
+ }
+
+ @Test
+ @DisplayName("IllegalArgumentException from service maps to 400 with its message")
+ void serviceIllegalArgument_isBadRequest() {
+ stubCurrentUser();
+ when(teamSecurityExpressions.isTeamLeader(10L)).thenReturn(true);
+ when(saasTeamService.inviteUserToTeam(eq(10L), eq("bob@example.com"), any()))
+ .thenThrow(new IllegalArgumentException("User is already a team member"));
+
+ ResponseEntity> response = controller.inviteUser(request(10L, "bob@example.com"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "User is already a team member");
+ }
+
+ @Test
+ @DisplayName("SecurityException from service maps to 400 with its message")
+ void serviceSecurityException_isBadRequest() {
+ stubCurrentUser();
+ when(teamSecurityExpressions.isTeamLeader(10L)).thenReturn(true);
+ when(saasTeamService.inviteUserToTeam(eq(10L), eq("bob@example.com"), any()))
+ .thenThrow(new SecurityException("Only team leaders can invite members"));
+
+ ResponseEntity> response = controller.inviteUser(request(10L, "bob@example.com"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response))
+ .containsEntry("error", "Only team leaders can invite members");
+ }
+
+ @Test
+ @DisplayName("unexpected RuntimeException maps to 500 with a generic message")
+ void unexpectedError_isServerError() {
+ stubCurrentUser();
+ when(teamSecurityExpressions.isTeamLeader(10L)).thenReturn(true);
+ when(saasTeamService.inviteUserToTeam(eq(10L), eq("bob@example.com"), any()))
+ .thenThrow(new RuntimeException("db down"));
+
+ ResponseEntity> response = controller.inviteUser(request(10L, "bob@example.com"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to send invitation");
+ }
+
+ @Test
+ @DisplayName("getCurrentUser failure (user not found) is caught as 400 SecurityException")
+ void currentUserNotFound_isBadRequest() {
+ when(userService.getCurrentUsername()).thenReturn(CURRENT_USERNAME);
+ when(userService.findByUsername(CURRENT_USERNAME)).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.inviteUser(request(10L, "bob@example.com"));
+
+ // getCurrentUser throws SecurityException, caught by the (SecurityException|IAE)
+ // branch.
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response))
+ .containsEntry("error", "User not found: " + CURRENT_USERNAME);
+ verify(teamSecurityExpressions, never()).isTeamLeader(anyLong());
+ }
+ }
+
+ @Nested
+ @DisplayName("acceptInvitation")
+ class AcceptInvitation {
+
+ @Test
+ @DisplayName("happy path returns 200 success message")
+ void happyPath() throws Exception {
+ stubCurrentUser();
+
+ ResponseEntity> response = controller.acceptInvitation("tok-1");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("message", "Invitation accepted");
+ assertThat(body(response)).containsEntry("success", true);
+ verify(saasTeamService).acceptInvitationAndGrantRole("tok-1", currentUser);
+ }
+
+ @Test
+ @DisplayName("IllegalStateException (expired/already-accepted) maps to 400 and rolls back")
+ void callerFixableFailure_isBadRequest() throws Exception {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ doThrow(new IllegalStateException("Invitation expired"))
+ .when(saasTeamService)
+ .acceptInvitationAndGrantRole("tok-1", currentUser);
+
+ ResponseEntity> response = controller.acceptInvitation("tok-1");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Invitation expired");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+
+ @Test
+ @DisplayName("unexpected error maps to 500 and rolls back")
+ void unexpectedError_isServerError() throws Exception {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ doThrow(new RuntimeException("boom"))
+ .when(saasTeamService)
+ .acceptInvitationAndGrantRole("tok-1", currentUser);
+
+ ResponseEntity> response = controller.acceptInvitation("tok-1");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to accept invitation");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("rejectInvitation")
+ class RejectInvitation {
+
+ @Test
+ @DisplayName(
+ "happy path: pending invitation for the current user is set REJECTED and saved")
+ void happyPath() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(
+ 5L,
+ team,
+ user(2L, "leader", "lead@x.com"),
+ CURRENT_EMAIL,
+ InvitationStatus.PENDING);
+ when(invitationRepository.findByInvitationToken("tok-5")).thenReturn(Optional.of(inv));
+
+ ResponseEntity> response = controller.rejectInvitation("tok-5");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("message", "Invitation rejected");
+ assertThat(inv.getStatus()).isEqualTo(InvitationStatus.REJECTED);
+ verify(invitationRepository).save(inv);
+ }
+
+ @Test
+ @DisplayName("invitation matched by username (not email) is also accepted")
+ void matchedByUsername() {
+ stubCurrentUser();
+ TeamInvitation inv =
+ invitation(
+ 6L,
+ team(10L, "Acme"),
+ user(2L, "leader", "lead@x.com"),
+ CURRENT_USERNAME,
+ InvitationStatus.PENDING);
+ when(invitationRepository.findByInvitationToken("tok-6")).thenReturn(Optional.of(inv));
+
+ ResponseEntity> response = controller.rejectInvitation("tok-6");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(inv.getStatus()).isEqualTo(InvitationStatus.REJECTED);
+ }
+
+ @Test
+ @DisplayName("missing invitation maps to 404")
+ void notFound() {
+ stubCurrentUser();
+ when(invitationRepository.findByInvitationToken("nope")).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.rejectInvitation("nope");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(body(response)).containsEntry("error", "Invitation not found");
+ verify(invitationRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("invitation addressed to someone else maps to 403 (security)")
+ void wrongRecipient_forbidden() {
+ stubCurrentUser();
+ TeamInvitation inv =
+ invitation(
+ 7L,
+ team(10L, "Acme"),
+ user(2L, "leader", "lead@x.com"),
+ "someone-else@x.com",
+ InvitationStatus.PENDING);
+ when(invitationRepository.findByInvitationToken("tok-7")).thenReturn(Optional.of(inv));
+
+ ResponseEntity> response = controller.rejectInvitation("tok-7");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ assertThat(body(response))
+ .containsEntry(
+ "error", "You cannot reject an invitation that was not sent to you");
+ verify(invitationRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("non-pending invitation maps to 403 (illegal state)")
+ void nonPending_forbidden() {
+ stubCurrentUser();
+ TeamInvitation inv =
+ invitation(
+ 8L,
+ team(10L, "Acme"),
+ user(2L, "leader", "lead@x.com"),
+ CURRENT_EMAIL,
+ InvitationStatus.ACCEPTED);
+ when(invitationRepository.findByInvitationToken("tok-8")).thenReturn(Optional.of(inv));
+
+ ResponseEntity> response = controller.rejectInvitation("tok-8");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ assertThat(body(response))
+ .containsEntry("error", "Can only reject pending invitations");
+ verify(invitationRepository, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("cancelInvitation")
+ class CancelInvitation {
+
+ @Test
+ @DisplayName("leader cancels a pending invitation -> 200 and status CANCELLED")
+ void happyPath() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(11L, team, currentUser, "bob@x.com", InvitationStatus.PENDING);
+ when(invitationRepository.findById(11L)).thenReturn(Optional.of(inv));
+ TeamMembership leaderMembership = membership(team, currentUser, TeamRole.LEADER);
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.of(leaderMembership));
+
+ ResponseEntity> response = controller.cancelInvitation(11L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("message", "Invitation cancelled");
+ assertThat(inv.getStatus()).isEqualTo(InvitationStatus.CANCELLED);
+ verify(invitationRepository).save(inv);
+ }
+
+ @Test
+ @DisplayName("missing invitation -> 404")
+ void notFound() {
+ stubCurrentUser();
+ when(invitationRepository.findById(11L)).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.cancelInvitation(11L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(body(response)).containsEntry("error", "Invitation not found");
+ }
+
+ @Test
+ @DisplayName("caller not a member of the team -> 403")
+ void notAMember_forbidden() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(11L, team, currentUser, "bob@x.com", InvitationStatus.PENDING);
+ when(invitationRepository.findById(11L)).thenReturn(Optional.of(inv));
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.cancelInvitation(11L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ assertThat(body(response)).containsEntry("error", "You are not a member of this team");
+ verify(invitationRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("member but not leader -> 403")
+ void memberNotLeader_forbidden() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(11L, team, currentUser, "bob@x.com", InvitationStatus.PENDING);
+ when(invitationRepository.findById(11L)).thenReturn(Optional.of(inv));
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.of(membership(team, currentUser, TeamRole.MEMBER)));
+
+ ResponseEntity> response = controller.cancelInvitation(11L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ assertThat(body(response))
+ .containsEntry("error", "Only team leaders can cancel invitations");
+ verify(invitationRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("non-pending invitation -> 403 (illegal state)")
+ void nonPending_forbidden() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(11L, team, currentUser, "bob@x.com", InvitationStatus.CANCELLED);
+ when(invitationRepository.findById(11L)).thenReturn(Optional.of(inv));
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.of(membership(team, currentUser, TeamRole.LEADER)));
+
+ ResponseEntity> response = controller.cancelInvitation(11L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ assertThat(body(response))
+ .containsEntry("error", "Can only cancel pending invitations");
+ verify(invitationRepository, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("getPendingInvitations")
+ class GetPendingInvitations {
+
+ @Test
+ @DisplayName("returns DTOs for the current user's pending invitations")
+ void happyPath() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(
+ 20L,
+ team,
+ user(2L, "leader", "lead@x.com"),
+ CURRENT_EMAIL,
+ InvitationStatus.PENDING);
+ when(invitationRepository.findPendingInvitationsByEmail(eq(CURRENT_EMAIL), any()))
+ .thenReturn(List.of(inv));
+
+ ResponseEntity> response = controller.getPendingInvitations();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ @SuppressWarnings("unchecked")
+ List dtos =
+ (List) response.getBody();
+ assertThat(dtos).hasSize(1);
+ assertThat(dtos.get(0).getInvitationId()).isEqualTo(20L);
+ assertThat(dtos.get(0).getInviteeEmail()).isEqualTo(CURRENT_EMAIL);
+ }
+
+ @Test
+ @DisplayName("empty list returns 200 with an empty body")
+ void empty() {
+ stubCurrentUser();
+ when(invitationRepository.findPendingInvitationsByEmail(eq(CURRENT_EMAIL), any()))
+ .thenReturn(List.of());
+
+ ResponseEntity> response = controller.getPendingInvitations();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat((List>) response.getBody()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("repository failure maps to 500")
+ void repoFailure_isServerError() {
+ stubCurrentUser();
+ when(invitationRepository.findPendingInvitationsByEmail(anyString(), any()))
+ .thenThrow(new RuntimeException("db down"));
+
+ ResponseEntity> response = controller.getPendingInvitations();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to fetch invitations");
+ }
+ }
+
+ @Nested
+ @DisplayName("getMyTeams")
+ class GetMyTeams {
+
+ @Test
+ @DisplayName(
+ "no memberships -> personal team created, then memberships re-fetched and returned")
+ void noMemberships_createsPersonalTeam() {
+ stubCurrentUser();
+ Team personal = team(1L, "My Team");
+ when(membershipRepository.findByUserId(currentUser.getId()))
+ .thenReturn(List.of()) // first call: empty
+ .thenReturn(List.of(membership(personal, currentUser, TeamRole.LEADER)));
+ when(saasTeamExtensionService.isPersonal(personal)).thenReturn(true);
+ when(saasTeamExtensionService.getTeamType(personal)).thenReturn("PERSONAL");
+ when(membershipRepository.countByTeamId(1L)).thenReturn(1L);
+ when(saasTeamExtensionService.getMaxSeats(personal)).thenReturn(1);
+ when(saasTeamExtensionService.getSeatsUsed(personal)).thenReturn(1);
+
+ ResponseEntity> response = controller.getMyTeams();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasTeamService).createPersonalTeam(currentUser);
+ @SuppressWarnings("unchecked")
+ List dtos =
+ (List) response.getBody();
+ assertThat(dtos).hasSize(1);
+ assertThat(dtos.get(0).getTeamId()).isEqualTo(1L);
+ assertThat(dtos.get(0).getIsPersonal()).isTrue();
+ assertThat(dtos.get(0).getIsLeader()).isTrue();
+ assertThat(dtos.get(0).getMemberCount()).isEqualTo(1);
+ assertThat(dtos.get(0).getMaxSeats()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("already has a personal team -> no migration, returns existing teams")
+ void existingPersonalTeam_noMigration() {
+ stubCurrentUser();
+ Team personal = team(1L, "My Team");
+ when(membershipRepository.findByUserId(currentUser.getId()))
+ .thenReturn(List.of(membership(personal, currentUser, TeamRole.LEADER)));
+ when(saasTeamExtensionService.isPersonal(personal)).thenReturn(true);
+ when(saasTeamExtensionService.getTeamType(personal)).thenReturn("PERSONAL");
+ when(membershipRepository.countByTeamId(1L)).thenReturn(1L);
+ when(saasTeamExtensionService.getMaxSeats(personal)).thenReturn(1);
+ when(saasTeamExtensionService.getSeatsUsed(personal)).thenReturn(1);
+
+ ResponseEntity> response = controller.getMyTeams();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasTeamService, never()).createPersonalTeam(any());
+ }
+
+ @Test
+ @DisplayName("only on legacy Default team -> migrates to a personal team")
+ void onlyOnDefaultTeam_migrates() {
+ stubCurrentUser();
+ Team legacy = team(2L, "Default");
+ Team personal = team(1L, "My Team");
+ when(membershipRepository.findByUserId(currentUser.getId()))
+ .thenReturn(List.of(membership(legacy, currentUser, TeamRole.MEMBER)))
+ .thenReturn(List.of(membership(personal, currentUser, TeamRole.LEADER)));
+ // Team equals() (Lombok onlyExplicitlyIncluded with no fields) treats all Team
+ // instances as equal, so isPersonal cannot be stubbed per-instance. The legacy team
+ // being non-personal plus the "Default" name is what triggers migration here.
+ when(saasTeamExtensionService.isPersonal(any(Team.class))).thenReturn(false);
+ when(saasTeamExtensionService.getTeamType(any(Team.class))).thenReturn("STANDARD");
+ when(membershipRepository.countByTeamId(anyLong())).thenReturn(1L);
+ when(saasTeamExtensionService.getMaxSeats(any(Team.class))).thenReturn(1);
+ when(saasTeamExtensionService.getSeatsUsed(any(Team.class))).thenReturn(1);
+
+ ResponseEntity> response = controller.getMyTeams();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasTeamService).createPersonalTeam(currentUser);
+ }
+
+ @Test
+ @DisplayName("on a real (non-system, non-personal) team -> no migration")
+ void onRealTeam_noMigration() {
+ stubCurrentUser();
+ Team realTeam = team(3L, "Engineering");
+ when(membershipRepository.findByUserId(currentUser.getId()))
+ .thenReturn(List.of(membership(realTeam, currentUser, TeamRole.MEMBER)));
+ when(saasTeamExtensionService.isPersonal(realTeam)).thenReturn(false);
+ when(saasTeamExtensionService.getTeamType(realTeam)).thenReturn("STANDARD");
+ when(membershipRepository.countByTeamId(3L)).thenReturn(4L);
+ when(saasTeamExtensionService.getMaxSeats(realTeam)).thenReturn(10);
+ when(saasTeamExtensionService.getSeatsUsed(realTeam)).thenReturn(4);
+
+ ResponseEntity> response = controller.getMyTeams();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasTeamService, never()).createPersonalTeam(any());
+ @SuppressWarnings("unchecked")
+ List dtos =
+ (List) response.getBody();
+ assertThat(dtos.get(0).getIsLeader()).isFalse();
+ assertThat(dtos.get(0).getMemberCount()).isEqualTo(4);
+ assertThat(dtos.get(0).getMaxSeats()).isEqualTo(10);
+ assertThat(dtos.get(0).getSeatsUsed()).isEqualTo(4);
+ }
+
+ @Test
+ @DisplayName("personal-team creation failure surfaces as 500")
+ void createPersonalTeamFails_isServerError() {
+ stubCurrentUser();
+ when(membershipRepository.findByUserId(currentUser.getId())).thenReturn(List.of());
+ when(saasTeamService.createPersonalTeam(currentUser))
+ .thenThrow(new RuntimeException("insert failed"));
+
+ ResponseEntity> response = controller.getMyTeams();
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to fetch teams");
+ }
+ }
+
+ @Nested
+ @DisplayName("getTeamMembers")
+ class GetTeamMembers {
+
+ @Test
+ @DisplayName("returns member DTOs for the team")
+ void happyPath() {
+ Team team = team(10L, "Acme");
+ User bob = user(2L, "bob", "bob@x.com");
+ when(membershipRepository.findByTeamId(10L))
+ .thenReturn(List.of(membership(team, bob, TeamRole.MEMBER)));
+
+ ResponseEntity> response = controller.getTeamMembers(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ @SuppressWarnings("unchecked")
+ List dtos =
+ (List) response.getBody();
+ assertThat(dtos).hasSize(1);
+ assertThat(dtos.get(0).getId()).isEqualTo(2L);
+ assertThat(dtos.get(0).getUsername()).isEqualTo("bob");
+ assertThat(dtos.get(0).getRole()).isEqualTo("MEMBER");
+ }
+
+ @Test
+ @DisplayName("repository failure maps to 500")
+ void repoFailure_isServerError() {
+ when(membershipRepository.findByTeamId(10L)).thenThrow(new RuntimeException("db"));
+
+ ResponseEntity> response = controller.getTeamMembers(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to fetch team members");
+ }
+ }
+
+ @Nested
+ @DisplayName("getTeamInvitations")
+ class GetTeamInvitations {
+
+ @Test
+ @DisplayName("returns invitation DTOs for the team")
+ void happyPath() {
+ Team team = team(10L, "Acme");
+ TeamInvitation inv =
+ invitation(30L, team, currentUser, "bob@x.com", InvitationStatus.PENDING);
+ when(invitationRepository.findByTeamId(10L)).thenReturn(List.of(inv));
+
+ ResponseEntity> response = controller.getTeamInvitations(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ @SuppressWarnings("unchecked")
+ List dtos =
+ (List) response.getBody();
+ assertThat(dtos).hasSize(1);
+ assertThat(dtos.get(0).getInvitationId()).isEqualTo(30L);
+ }
+
+ @Test
+ @DisplayName("repository failure maps to 500")
+ void repoFailure_isServerError() {
+ when(invitationRepository.findByTeamId(10L)).thenThrow(new RuntimeException("db"));
+
+ ResponseEntity> response = controller.getTeamInvitations(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to fetch invitations");
+ }
+ }
+
+ @Nested
+ @DisplayName("removeTeamMember")
+ class RemoveTeamMember {
+
+ @Test
+ @DisplayName("removes member and revokes PRO role when the removed user was PRO")
+ void happyPath_revokesProRole() throws Exception {
+ stubCurrentUser();
+ User proMember = user(2L, "bob", "bob@x.com");
+ addRole(proMember, Role.PRO_USER.getRoleId());
+ when(userRepository.findById(2L)).thenReturn(Optional.of(proMember));
+ Team team = team(10L, "Acme");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+
+ ResponseEntity> response = controller.removeTeamMember(10L, 2L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("message", "Member removed successfully");
+ verify(saasTeamService).removeTeamMember(10L, 2L, currentUser);
+ verify(userService).changeRole(proMember, Role.USER.getRoleId());
+ }
+
+ @Test
+ @DisplayName("non-PRO removed user is not downgraded")
+ void happyPath_nonProUntouched() throws Exception {
+ stubCurrentUser();
+ User member = user(2L, "bob", "bob@x.com");
+ addRole(member, Role.USER.getRoleId());
+ when(userRepository.findById(2L)).thenReturn(Optional.of(member));
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team(10L, "Acme")));
+
+ ResponseEntity> response = controller.removeTeamMember(10L, 2L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(userService, never()).changeRole(any(), anyString());
+ }
+
+ @Test
+ @DisplayName("member not found -> 400 and rollback")
+ void memberNotFound_isBadRequest() {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ when(userRepository.findById(2L)).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.removeTeamMember(10L, 2L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Member not found");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+
+ @Test
+ @DisplayName("service SecurityException -> 400 and rollback")
+ void serviceSecurityException_isBadRequest() {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ when(userRepository.findById(2L))
+ .thenReturn(Optional.of(user(2L, "bob", "b@x.com")));
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team(10L, "Acme")));
+ doThrow(new SecurityException("Only team leaders can remove members"))
+ .when(saasTeamService)
+ .removeTeamMember(10L, 2L, currentUser);
+
+ ResponseEntity> response = controller.removeTeamMember(10L, 2L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response))
+ .containsEntry("error", "Only team leaders can remove members");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+
+ @Test
+ @DisplayName("unexpected error -> 500 and rollback")
+ void unexpectedError_isServerError() {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ when(userRepository.findById(2L))
+ .thenReturn(Optional.of(user(2L, "bob", "b@x.com")));
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team(10L, "Acme")));
+ doThrow(new RuntimeException("boom"))
+ .when(saasTeamService)
+ .removeTeamMember(10L, 2L, currentUser);
+
+ ResponseEntity> response = controller.removeTeamMember(10L, 2L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to remove member");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("leaveTeam")
+ class LeaveTeam {
+
+ @Test
+ @DisplayName("PRO user leaving has PRO role revoked")
+ void happyPath_revokesProRole() throws Exception {
+ stubCurrentUser();
+ addRole(currentUser, Role.PRO_USER.getRoleId());
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team(10L, "Acme")));
+
+ ResponseEntity> response = controller.leaveTeam(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("message", "Left team successfully");
+ verify(saasTeamService).leaveTeam(10L, currentUser);
+ verify(userService).changeRole(currentUser, Role.USER.getRoleId());
+ }
+
+ @Test
+ @DisplayName("non-PRO user leaving is not downgraded")
+ void happyPath_nonProUntouched() throws Exception {
+ stubCurrentUser();
+ addRole(currentUser, Role.USER.getRoleId());
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team(10L, "Acme")));
+
+ ResponseEntity> response = controller.leaveTeam(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(userService, never()).changeRole(any(), anyString());
+ }
+
+ @Test
+ @DisplayName("last-leader IllegalStateException -> 400 and rollback")
+ void lastLeader_isBadRequest() {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team(10L, "Acme")));
+ doThrow(new IllegalStateException("Cannot leave as the last team leader."))
+ .when(saasTeamService)
+ .leaveTeam(10L, currentUser);
+
+ ResponseEntity> response = controller.leaveTeam(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response))
+ .containsEntry("error", "Cannot leave as the last team leader.");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+
+ @Test
+ @DisplayName("unexpected error -> 500 and rollback")
+ void unexpectedError_isServerError() {
+ stubCurrentUser();
+ TransactionSupport tx = TransactionSupport.bind();
+ try {
+ doThrow(new RuntimeException("boom")).when(teamRepository).findById(10L);
+
+ ResponseEntity> response = controller.leaveTeam(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to leave team");
+ verify(tx.status()).setRollbackOnly();
+ } finally {
+ tx.unbind();
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("renameTeamByLeader")
+ class RenameTeam {
+
+ private RenameTeamRequest req(String name) {
+ RenameTeamRequest r = new RenameTeamRequest();
+ r.setNewName(name);
+ return r;
+ }
+
+ @Test
+ @DisplayName("happy path renames a standard team and trims the name")
+ void happyPath() {
+ stubCurrentUser();
+ Team team = team(10L, "Old Name");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req(" New Name "));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("message", "Team renamed successfully");
+ assertThat(body(response)).containsEntry("newName", "New Name");
+ assertThat(team.getName()).isEqualTo("New Name");
+ verify(teamRepository).save(team);
+ }
+
+ @Test
+ @DisplayName("blank name -> 400 before any lookup")
+ void blankName_isBadRequest() {
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req(" "));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Team name cannot be empty");
+ verify(teamRepository, never()).findById(anyLong());
+ }
+
+ @Test
+ @DisplayName("null name -> 400")
+ void nullName_isBadRequest() {
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req(null));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Team name cannot be empty");
+ }
+
+ @Test
+ @DisplayName("team not found -> 400 with message")
+ void teamNotFound_isBadRequest() {
+ when(teamRepository.findById(10L)).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req("New"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Team not found");
+ }
+
+ @Test
+ @DisplayName("personal team cannot be renamed -> 400")
+ void personalTeam_isBadRequest() {
+ Team team = team(10L, "My Team");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(true);
+
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req("New"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Cannot rename personal team");
+ verify(teamRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("Internal team cannot be renamed -> 400")
+ void internalTeam_isBadRequest() {
+ Team team = team(10L, TeamService.INTERNAL_TEAM_NAME);
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req("New"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "Cannot rename Internal team");
+ verify(teamRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("persistence failure -> 500")
+ void saveFailure_isServerError() {
+ stubCurrentUser();
+ Team team = team(10L, "Old");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(teamRepository.save(team)).thenThrow(new RuntimeException("db"));
+
+ ResponseEntity> response = controller.renameTeamByLeader(10L, req("New"));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to rename team");
+ }
+ }
+
+ @Nested
+ @DisplayName("updateTeamSeats")
+ class UpdateTeamSeats {
+
+ private UpdateSeatsRequest req(Integer maxSeats) {
+ UpdateSeatsRequest r = new UpdateSeatsRequest();
+ r.setMaxSeats(maxSeats);
+ return r;
+ }
+
+ @Test
+ @DisplayName("happy path returns seat math (available = max - used)")
+ void happyPath() {
+ Team team = team(10L, "Acme");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(saasTeamExtensionService.getMaxSeats(team)).thenReturn(10);
+ when(saasTeamExtensionService.getSeatsUsed(team)).thenReturn(3);
+
+ ResponseEntity> response = controller.updateTeamSeats(10L, req(10));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasTeamService).updateTeamSeats(10L, 10);
+ assertThat(body(response)).containsEntry("success", true);
+ assertThat(body(response)).containsEntry("teamId", 10L);
+ assertThat(body(response)).containsEntry("maxSeats", 10);
+ assertThat(body(response)).containsEntry("seatsUsed", 3);
+ assertThat(body(response)).containsEntry("availableSeats", 7);
+ }
+
+ @Test
+ @DisplayName("invalid seats (service IllegalArgumentException) -> 400")
+ void invalidSeats_isBadRequest() {
+ doThrow(new IllegalArgumentException("maxSeats must be at least 1"))
+ .when(saasTeamService)
+ .updateTeamSeats(10L, 0);
+
+ ResponseEntity> response = controller.updateTeamSeats(10L, req(0));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response)).containsEntry("error", "maxSeats must be at least 1");
+ }
+
+ @Test
+ @DisplayName("unexpected error -> 500")
+ void unexpectedError_isServerError() {
+ doThrow(new RuntimeException("boom")).when(saasTeamService).updateTeamSeats(10L, 5);
+
+ ResponseEntity> response = controller.updateTeamSeats(10L, req(5));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to update team seats");
+ }
+ }
+
+ @Nested
+ @DisplayName("getUserPrimaryTeamBySupabaseId")
+ class GetUserPrimaryTeam {
+
+ @Test
+ @DisplayName("happy path returns the user's primary team payload")
+ void happyPath() {
+ UUID uuid = UUID.randomUUID();
+ User user = user(2L, "bob", "bob@x.com");
+ user.setSupabaseId(uuid);
+ Team primary = team(10L, "Acme");
+ user.setTeam(primary);
+ when(userRepository.findBySupabaseId(uuid)).thenReturn(Optional.of(user));
+ when(saasTeamExtensionService.isPersonal(primary)).thenReturn(false);
+ when(saasTeamExtensionService.getMaxSeats(primary)).thenReturn(10);
+
+ ResponseEntity> response = controller.getUserPrimaryTeamBySupabaseId(uuid.toString());
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("teamId", 10L);
+ assertThat(body(response)).containsEntry("userId", 2L);
+ assertThat(body(response)).containsEntry("supabaseUserId", uuid.toString());
+ assertThat(body(response)).containsEntry("isPersonal", false);
+ assertThat(body(response)).containsEntry("maxSeats", 10);
+ }
+
+ @Test
+ @DisplayName("malformed UUID -> 400 generic message")
+ void malformedUuid_isBadRequest() {
+ ResponseEntity> response = controller.getUserPrimaryTeamBySupabaseId("not-a-uuid");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response))
+ .containsEntry("error", "Invalid UUID format or user not found");
+ }
+
+ @Test
+ @DisplayName("unknown user -> 400 generic message")
+ void unknownUser_isBadRequest() {
+ UUID uuid = UUID.randomUUID();
+ when(userRepository.findBySupabaseId(uuid)).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.getUserPrimaryTeamBySupabaseId(uuid.toString());
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(body(response))
+ .containsEntry("error", "Invalid UUID format or user not found");
+ }
+
+ @Test
+ @DisplayName("user with no primary team -> 404")
+ void noPrimaryTeam_isNotFound() {
+ UUID uuid = UUID.randomUUID();
+ User user = user(2L, "bob", "bob@x.com");
+ user.setSupabaseId(uuid);
+ user.setTeam(null);
+ when(userRepository.findBySupabaseId(uuid)).thenReturn(Optional.of(user));
+
+ ResponseEntity> response = controller.getUserPrimaryTeamBySupabaseId(uuid.toString());
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(body(response)).containsEntry("error", "User has no primary team");
+ }
+ }
+
+ @Nested
+ @DisplayName("getTeamInfo")
+ class GetTeamInfo {
+
+ @Test
+ @DisplayName("happy path: leader sees full payload with members and seat math")
+ void happyPath_leader() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ User bob = user(2L, "bob", "bob@x.com");
+ when(membershipRepository.findByTeamId(10L))
+ .thenReturn(List.of(membership(team, bob, TeamRole.MEMBER)));
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.of(membership(team, currentUser, TeamRole.LEADER)));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(saasTeamExtensionService.getMaxSeats(team)).thenReturn(10);
+ when(saasTeamExtensionService.getSeatsUsed(team)).thenReturn(2);
+
+ ResponseEntity> response = controller.getTeamInfo(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("teamId", 10L);
+ assertThat(body(response)).containsEntry("name", "Acme");
+ assertThat(body(response)).containsEntry("isPersonal", false);
+ assertThat(body(response)).containsEntry("maxSeats", 10);
+ assertThat(body(response)).containsEntry("seatsUsed", 2);
+ assertThat(body(response)).containsEntry("availableSeats", 8);
+ assertThat(body(response)).containsEntry("isLeader", true);
+ assertThat(body(response)).containsKey("members");
+ }
+
+ @Test
+ @DisplayName("non-leader member sees isLeader=false")
+ void nonLeader() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(membershipRepository.findByTeamId(10L)).thenReturn(List.of());
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.of(membership(team, currentUser, TeamRole.MEMBER)));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(saasTeamExtensionService.getMaxSeats(team)).thenReturn(5);
+ when(saasTeamExtensionService.getSeatsUsed(team)).thenReturn(1);
+
+ ResponseEntity> response = controller.getTeamInfo(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("isLeader", false);
+ }
+
+ @Test
+ @DisplayName("no membership row -> isLeader defaults to false")
+ void noMembershipRow_leaderFalse() {
+ stubCurrentUser();
+ Team team = team(10L, "Acme");
+ when(teamRepository.findById(10L)).thenReturn(Optional.of(team));
+ when(membershipRepository.findByTeamId(10L)).thenReturn(List.of());
+ when(membershipRepository.findByTeamIdAndUserId(10L, currentUser.getId()))
+ .thenReturn(Optional.empty());
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(saasTeamExtensionService.getMaxSeats(team)).thenReturn(5);
+ when(saasTeamExtensionService.getSeatsUsed(team)).thenReturn(1);
+
+ ResponseEntity> response = controller.getTeamInfo(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(body(response)).containsEntry("isLeader", false);
+ }
+
+ @Test
+ @DisplayName("team not found -> 404")
+ void teamNotFound_isNotFound() {
+ when(teamRepository.findById(10L)).thenReturn(Optional.empty());
+
+ ResponseEntity> response = controller.getTeamInfo(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(body(response)).containsEntry("error", "Team not found");
+ }
+
+ @Test
+ @DisplayName("unexpected error -> 500")
+ void unexpectedError_isServerError() {
+ when(teamRepository.findById(10L)).thenThrow(new RuntimeException("db"));
+
+ ResponseEntity> response = controller.getTeamInfo(10L);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(body(response)).containsEntry("error", "Failed to fetch team info");
+ }
+ }
+
+ @Nested
+ @DisplayName("DTO value holders")
+ class Dtos {
+
+ @Test
+ @DisplayName("TeamDetailsDTO wires constructor fields verbatim")
+ void teamDetailsDto() {
+ SaasTeamController.TeamDetailsDTO dto =
+ new SaasTeamController.TeamDetailsDTO(
+ 1L, "Acme", "STANDARD", false, 3, 10, 4, 10, true);
+ assertThat(dto.getTeamId()).isEqualTo(1L);
+ assertThat(dto.getName()).isEqualTo("Acme");
+ assertThat(dto.getTeamType()).isEqualTo("STANDARD");
+ assertThat(dto.getIsPersonal()).isFalse();
+ assertThat(dto.getMemberCount()).isEqualTo(3);
+ assertThat(dto.getSeatCount()).isEqualTo(10);
+ assertThat(dto.getSeatsUsed()).isEqualTo(4);
+ assertThat(dto.getMaxSeats()).isEqualTo(10);
+ assertThat(dto.getIsLeader()).isTrue();
+ }
+
+ @Test
+ @DisplayName("InviteUserRequest is a mutable POJO")
+ void inviteUserRequest() {
+ InviteUserRequest r = new InviteUserRequest();
+ r.setTeamId(5L);
+ r.setEmail("x@y.com");
+ assertThat(r.getTeamId()).isEqualTo(5L);
+ assertThat(r.getEmail()).isEqualTo("x@y.com");
+ }
+ }
+
+ // ===== shared test infrastructure =====
+
+ private static void addRole(User user, String roleId) {
+ // Authority's (String, User) ctor self-registers on the user's authority set.
+ new stirling.software.proprietary.security.model.Authority(roleId, user);
+ }
+
+ /**
+ * Binds a Spring transaction context to the current thread so {@code @Transactional} handlers
+ * can call {@link TransactionAspectSupport#currentTransactionStatus()} on their error paths
+ * without a live Spring transaction, and verify {@code setRollbackOnly()} on the resulting
+ * status.
+ *
+ * Spring's {@code TransactionInfo} type is {@code protected} and its {@code bindToThread()}
+ * plus the backing {@code transactionInfoHolder} ThreadLocal are {@code private}, so the whole
+ * binding is performed reflectively. {@link #unbind()} clears the ThreadLocal again so the
+ * binding never leaks into sibling tests.
+ */
+ private static final class TransactionSupport {
+
+ @SuppressWarnings("unchecked")
+ private static final ThreadLocal HOLDER = resolveHolder();
+
+ private final TransactionStatus status;
+
+ private TransactionSupport(TransactionStatus status) {
+ this.status = status;
+ HOLDER.set(newTransactionInfo(status));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ThreadLocal resolveHolder() {
+ try {
+ java.lang.reflect.Field field =
+ TransactionAspectSupport.class.getDeclaredField("transactionInfoHolder");
+ field.setAccessible(true);
+ return (ThreadLocal) field.get(null);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unable to access transactionInfoHolder", e);
+ }
+ }
+
+ /**
+ * Reflectively build a TransactionInfo exposing the given status (protected nested type).
+ */
+ private static Object newTransactionInfo(TransactionStatus status) {
+ try {
+ Class> infoClass =
+ Class.forName(
+ "org.springframework.transaction.interceptor."
+ + "TransactionAspectSupport$TransactionInfo");
+ java.lang.reflect.Constructor> ctor =
+ infoClass.getDeclaredConstructor(
+ org.springframework.transaction.PlatformTransactionManager.class,
+ org.springframework.transaction.interceptor.TransactionAttribute
+ .class,
+ String.class);
+ ctor.setAccessible(true);
+ Object info = ctor.newInstance(null, null, "test");
+ java.lang.reflect.Method newStatus =
+ infoClass.getDeclaredMethod(
+ "newTransactionStatus", TransactionStatus.class);
+ newStatus.setAccessible(true);
+ newStatus.invoke(info, status);
+ return info;
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unable to build TransactionInfo", e);
+ }
+ }
+
+ static TransactionSupport bind() {
+ return new TransactionSupport(mock(TransactionStatus.class));
+ }
+
+ TransactionStatus status() {
+ return status;
+ }
+
+ void unbind() {
+ HOLDER.remove();
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/controller/UserRoleWebhookControllerTest.java b/app/saas/src/test/java/stirling/software/saas/controller/UserRoleWebhookControllerTest.java
new file mode 100644
index 000000000..e0ff22145
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/controller/UserRoleWebhookControllerTest.java
@@ -0,0 +1,556 @@
+package stirling.software.saas.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.security.Principal;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import stirling.software.proprietary.security.model.AuthenticationType;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.proprietary.security.service.UserService;
+import stirling.software.saas.model.SupabaseUser;
+import stirling.software.saas.service.SaasUserAccountService;
+import stirling.software.saas.service.SupabaseUserService;
+
+/**
+ * Pure-Mockito unit tests for {@link UserRoleWebhookController}.
+ *
+ * The controller is built via {@code @RequiredArgsConstructor}, so {@link InjectMocks} wires the
+ * three mocked collaborators ({@link UserService}, {@link SaasUserAccountService}, {@link
+ * SupabaseUserService}) by type. Each handler is invoked directly and the returned {@link
+ * ResponseEntity} (status + body) is asserted, alongside collaborator interaction verification. No
+ * Spring context, DB, Supabase or network is involved; {@code @PreAuthorize} is a no-op outside the
+ * security proxy so authorization is not exercised here.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class UserRoleWebhookControllerTest {
+
+ @Mock private UserService userService;
+ @Mock private SaasUserAccountService saasUserAccountService;
+ @Mock private SupabaseUserService supabaseUserService;
+
+ @InjectMocks private UserRoleWebhookController controller;
+
+ private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
+
+ @Nested
+ @DisplayName("POST /upgrade")
+ class HandleUpgrade {
+
+ @Test
+ @DisplayName("returns 200 with 'upgraded' message when a promotion happened")
+ void upgraded() {
+ when(saasUserAccountService.handleUpgrade(SUPABASE_ID)).thenReturn(true);
+
+ ResponseEntity response = controller.handleUpgrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("User upgraded to PRO successfully");
+ verify(saasUserAccountService).handleUpgrade(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("returns 200 with 'already PRO' message when nothing changed")
+ void alreadyPro() {
+ when(saasUserAccountService.handleUpgrade(SUPABASE_ID)).thenReturn(false);
+
+ ResponseEntity response = controller.handleUpgrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("User is already PRO");
+ }
+
+ @Test
+ @DisplayName(
+ "maps IllegalArgumentException (bad/unknown supabaseId) to 400 'Invalid request'")
+ void illegalArgumentMapsTo400() {
+ when(saasUserAccountService.handleUpgrade(SUPABASE_ID))
+ .thenThrow(new IllegalArgumentException("Invalid Supabase ID format"));
+
+ ResponseEntity response = controller.handleUpgrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).isEqualTo("Invalid request");
+ }
+
+ @Test
+ @DisplayName("maps any other exception to 500 'Error processing webhook'")
+ void unexpectedExceptionMapsTo500() {
+ when(saasUserAccountService.handleUpgrade(SUPABASE_ID))
+ .thenThrow(new RuntimeException("db down"));
+
+ ResponseEntity response = controller.handleUpgrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(response.getBody()).isEqualTo("Error processing webhook");
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /downgrade")
+ class HandleDowngrade {
+
+ @Test
+ @DisplayName("returns 200 with 'downgraded' message when a demotion happened")
+ void downgraded() {
+ when(saasUserAccountService.handleDowngrade(SUPABASE_ID)).thenReturn(true);
+
+ ResponseEntity response = controller.handleDowngrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("User downgraded to FREE successfully");
+ verify(saasUserAccountService).handleDowngrade(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("returns 200 with 'already FREE' message when nothing changed")
+ void alreadyFree() {
+ when(saasUserAccountService.handleDowngrade(SUPABASE_ID)).thenReturn(false);
+
+ ResponseEntity response = controller.handleDowngrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("User is already on FREE tier");
+ }
+
+ @Test
+ @DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
+ void illegalArgumentMapsTo400() {
+ when(saasUserAccountService.handleDowngrade(SUPABASE_ID))
+ .thenThrow(new IllegalArgumentException("User not found for Supabase ID"));
+
+ ResponseEntity response = controller.handleDowngrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).isEqualTo("Invalid request");
+ }
+
+ @Test
+ @DisplayName("maps any other exception to 500 'Error processing webhook'")
+ void unexpectedExceptionMapsTo500() {
+ when(saasUserAccountService.handleDowngrade(SUPABASE_ID))
+ .thenThrow(new RuntimeException("boom"));
+
+ ResponseEntity response = controller.handleDowngrade(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(response.getBody()).isEqualTo("Error processing webhook");
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /enable-metered-billing")
+ class EnableMeteredBilling {
+
+ @Test
+ @DisplayName("returns 200 'enabled' when metered billing is newly turned on")
+ void enabled() {
+ when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID)).thenReturn(true);
+
+ ResponseEntity response = controller.enableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("Metered billing enabled successfully");
+ verify(saasUserAccountService).enableMeteredBilling(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("returns 200 'already enabled' when no change was made")
+ void alreadyEnabled() {
+ when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID)).thenReturn(false);
+
+ ResponseEntity response = controller.enableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("User already has metered billing enabled");
+ }
+
+ @Test
+ @DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
+ void illegalArgumentMapsTo400() {
+ when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID))
+ .thenThrow(new IllegalArgumentException("Invalid Supabase ID format"));
+
+ ResponseEntity response = controller.enableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).isEqualTo("Invalid request");
+ }
+
+ @Test
+ @DisplayName("maps any other exception to 500 'Error processing webhook'")
+ void unexpectedExceptionMapsTo500() {
+ when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID))
+ .thenThrow(new RuntimeException("stripe down"));
+
+ ResponseEntity response = controller.enableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(response.getBody()).isEqualTo("Error processing webhook");
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /disable-metered-billing")
+ class DisableMeteredBilling {
+
+ @Test
+ @DisplayName("returns 200 'disabled' when metered billing is newly turned off")
+ void disabled() {
+ when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID)).thenReturn(true);
+
+ ResponseEntity response = controller.disableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("Metered billing disabled successfully");
+ verify(saasUserAccountService).disableMeteredBilling(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("returns 200 'does not have' when no change was made")
+ void notEnabled() {
+ when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID)).thenReturn(false);
+
+ ResponseEntity response = controller.disableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEqualTo("User does not have metered billing enabled");
+ }
+
+ @Test
+ @DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
+ void illegalArgumentMapsTo400() {
+ when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID))
+ .thenThrow(new IllegalArgumentException("User not found for Supabase ID"));
+
+ ResponseEntity response = controller.disableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).isEqualTo("Invalid request");
+ }
+
+ @Test
+ @DisplayName("maps any other exception to 500 'Error processing webhook'")
+ void unexpectedExceptionMapsTo500() {
+ when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID))
+ .thenThrow(new RuntimeException("kaboom"));
+
+ ResponseEntity response = controller.disableMeteredBilling(SUPABASE_ID);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(response.getBody()).isEqualTo("Error processing webhook");
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /promptToAuthUser")
+ class PromptToAuthUser {
+
+ private static final String USERNAME = "anon-user";
+ private static final UUID LINKED_SUPABASE_ID =
+ UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+
+ private Principal principal(String name) {
+ Principal p = org.mockito.Mockito.mock(Principal.class);
+ when(p.getName()).thenReturn(name);
+ return p;
+ }
+
+ private User anonymousUser(UUID supabaseId) {
+ User user = new User();
+ user.setUsername(USERNAME);
+ user.setSupabaseId(supabaseId);
+ user.setAuthenticationType(AuthenticationType.ANONYMOUS);
+ return user;
+ }
+
+ private SupabaseUser supabaseUserWithEmail(String email) {
+ SupabaseUser su = new SupabaseUser();
+ su.setId(LINKED_SUPABASE_ID);
+ su.setEmail(email);
+ su.setAnonymous(true);
+ return su;
+ }
+
+ @Test
+ @DisplayName("happy path: synchronizes upgrade and returns 200 with userId/email body")
+ void happyPath() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+
+ SupabaseUser supabaseUser = supabaseUserWithEmail("new@stirling.com");
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+
+ User upgraded = new User();
+ upgraded.setId(42L);
+ upgraded.setEmail("new@stirling.com");
+ upgraded.setUsername("new@stirling.com");
+ when(saasUserAccountService.synchronizeUserUpgrade(
+ supabaseUser, "new@stirling.com", "google"))
+ .thenReturn(upgraded);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("google", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody())
+ .containsEntry("message", "User upgrade synchronized successfully")
+ .containsEntry("userId", "42")
+ .containsEntry("email", "new@stirling.com");
+ }
+
+ @Test
+ @DisplayName("normalizes auth method to lowercase/trimmed before delegating")
+ void normalizesAuthMethod() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+
+ User upgraded = new User();
+ upgraded.setId(7L);
+ upgraded.setEmail("a@b.com");
+ when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
+ .thenReturn(upgraded);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser(" GitHub ", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ ArgumentCaptor methodCaptor = ArgumentCaptor.forClass(String.class);
+ verify(saasUserAccountService)
+ .synchronizeUserUpgrade(
+ eq(supabaseUser), eq("a@b.com"), methodCaptor.capture());
+ assertThat(methodCaptor.getValue()).isEqualTo("github");
+ }
+
+ @Test
+ @DisplayName("null authMethod is accepted and passed through as null")
+ void nullAuthMethodAccepted() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+
+ User upgraded = new User();
+ upgraded.setId(7L);
+ upgraded.setEmail("a@b.com");
+ when(saasUserAccountService.synchronizeUserUpgrade(
+ eq(supabaseUser), eq("a@b.com"), eq(null)))
+ .thenReturn(upgraded);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser(null, principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasUserAccountService).synchronizeUserUpgrade(supabaseUser, "a@b.com", null);
+ }
+
+ @Test
+ @DisplayName("falls back to username in body when upgraded user has no email")
+ void emailFallsBackToUsername() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ SupabaseUser supabaseUser = supabaseUserWithEmail("canon@b.com");
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+
+ User upgraded = new User();
+ upgraded.setId(9L);
+ upgraded.setEmail(null);
+ upgraded.setUsername("fallback-username");
+ when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
+ .thenReturn(upgraded);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).containsEntry("email", "fallback-username");
+ }
+
+ @Test
+ @DisplayName("invalid auth method returns 400 without touching userService")
+ void invalidAuthMethodRejected() {
+ ResponseEntity> response =
+ controller.promptToAuthUser("myspace", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).containsEntry("error", "Invalid authentication method");
+ verifyNoInteractions(userService);
+ verifyNoInteractions(saasUserAccountService);
+ }
+
+ @Test
+ @DisplayName("unknown current user (IllegalStateException) maps to 404 'User not found'")
+ void currentUserNotFound() {
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.empty());
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(response.getBody()).containsEntry("error", "User not found");
+ verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("current user without a linked Supabase ID returns 400")
+ void noLinkedSupabaseId() {
+ User current = anonymousUser(null);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody())
+ .containsEntry("error", "No Supabase account linked to current user");
+ verifyNoInteractions(supabaseUserService);
+ }
+
+ @Test
+ @DisplayName("non-anonymous user is rejected with 400")
+ void nonAnonymousRejected() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ current.setAuthenticationType(AuthenticationType.WEB);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID))
+ .thenReturn(supabaseUserWithEmail("x@y.com"));
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody())
+ .containsEntry("error", "Only anonymous users can be upgraded");
+ verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("falls back to local user email when Supabase email is blank")
+ void canonicalEmailFallsBackToLocal() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ current.setEmail("local@stirling.com");
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+
+ SupabaseUser supabaseUser = supabaseUserWithEmail(" "); // blank
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+
+ User upgraded = new User();
+ upgraded.setId(5L);
+ upgraded.setEmail("local@stirling.com");
+ when(saasUserAccountService.synchronizeUserUpgrade(
+ eq(supabaseUser), eq("local@stirling.com"), anyString()))
+ .thenReturn(upgraded);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ verify(saasUserAccountService)
+ .synchronizeUserUpgrade(supabaseUser, "local@stirling.com", "email");
+ }
+
+ @Test
+ @DisplayName("no email anywhere (Supabase and local both blank) returns 400")
+ void noEmailAnywhere() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ current.setEmail(null);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+
+ SupabaseUser supabaseUser = supabaseUserWithEmail(null);
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody())
+ .containsEntry("error", "No email associated with user account");
+ verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("unexpected RuntimeException from sync maps to 500")
+ void unexpectedExceptionMapsTo500() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+ when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
+ .thenThrow(new RuntimeException("db down"));
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(response.getBody())
+ .containsEntry("error", "Failed to synchronize user upgrade");
+ }
+
+ @Test
+ @DisplayName("getUser throwing (Supabase row missing) maps to 500")
+ void supabaseUserMissingMapsTo500() {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID))
+ .thenThrow(new RuntimeException("Supabase user not found"));
+
+ ResponseEntity> response =
+ controller.promptToAuthUser("email", principal(USERNAME));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(response.getBody())
+ .containsEntry("error", "Failed to synchronize user upgrade");
+ }
+
+ @Test
+ @DisplayName("all allowed auth methods are accepted (none rejected as invalid)")
+ void allowedAuthMethodsAccepted() {
+ for (String method :
+ new String[] {
+ "email", "oauth", "google", "github", "apple", "azure", "linkedin_oidc"
+ }) {
+ User current = anonymousUser(LINKED_SUPABASE_ID);
+ when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
+ SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
+ when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
+ User upgraded = new User();
+ upgraded.setId(1L);
+ upgraded.setEmail("a@b.com");
+ when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
+ .thenReturn(upgraded);
+
+ ResponseEntity> response =
+ controller.promptToAuthUser(method, principal(USERNAME));
+
+ assertThat(response.getStatusCode())
+ .as("method %s should be accepted", method)
+ .isEqualTo(HttpStatus.OK);
+ }
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java b/app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java
new file mode 100644
index 000000000..6d54444b8
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java
@@ -0,0 +1,725 @@
+package stirling.software.saas.interceptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.CreditConsumptionResult;
+import stirling.software.saas.service.CreditService;
+import stirling.software.saas.service.ErrorTrackingService;
+import stirling.software.saas.service.SaasTeamExtensionService;
+import stirling.software.saas.service.TeamCreditService;
+import stirling.software.saas.util.CreditHeaderUtils;
+
+/**
+ * Unit tests for {@link CreditErrorAdvice}.
+ *
+ * The advice is a {@code @RestControllerAdvice} that maps thrown exceptions to a status/body
+ * and, when the request was flagged eligible (and not already charged), consumes a credit through
+ * either the team pool or the individual waterfall. Collaborators are mocked; the {@link
+ * MeterRegistry} is a real {@link SimpleMeterRegistry} so the {@code credits.consumed} counter is
+ * exercised.
+ *
+ *
Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code
+ * UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link
+ * User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code
+ * instanceof User} without touching the repository.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CreditErrorAdviceTest {
+
+ private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
+ private static final String ATTR_APIKEY = "CREDIT_API_KEY";
+ private static final String ATTR_CHARGED = "CREDIT_CHARGED";
+ private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
+ private static final String ATTR_IS_API = "IS_API_REQUEST";
+
+ private static final String API_KEY = "apikey-abcdefgh";
+ private static final String URI = "/api/v1/convert/pdf-to-img";
+
+ @Mock private CreditService creditService;
+ @Mock private TeamCreditService teamCreditService;
+ @Mock private UserRepository userRepository;
+ @Mock private ErrorTrackingService errorTrackingService;
+ @Mock private SaasTeamExtensionService saasTeamExtensionService;
+ @Mock private CreditHeaderUtils creditHeaderUtils;
+
+ private MeterRegistry meterRegistry;
+ private CreditErrorAdvice advice;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ advice =
+ new CreditErrorAdvice(
+ creditService,
+ teamCreditService,
+ userRepository,
+ errorTrackingService,
+ saasTeamExtensionService,
+ creditHeaderUtils,
+ meterRegistry);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // --- helpers --------------------------------------------------------------------------------
+
+ private static User user(String username) {
+ User u = new User();
+ u.setUsername(username);
+ return u;
+ }
+
+ private static Team team(Long id) {
+ Team t = new Team();
+ t.setId(id);
+ return t;
+ }
+
+ /** Put the user on the SecurityContext as an authenticated principal. */
+ private void authenticate(User user) {
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken(
+ user, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ SecurityContextHolder.getContext().setAuthentication(token);
+ }
+
+ /** Base request that is credit-eligible with an api key, resource weight 1 and not charged. */
+ private MockHttpServletRequest eligibleRequest() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setRequestURI(URI);
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
+ req.setAttribute(ATTR_APIKEY, API_KEY);
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
+ return req;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map bodyOf(ResponseEntity resp) {
+ return (Map) resp.getBody();
+ }
+
+ private double counter() {
+ return meterRegistry.get("credits.consumed").counter().count();
+ }
+
+ // --- status mapping -------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("determineHttpStatus mapping (via handleThrowable)")
+ class StatusMapping {
+
+ @Test
+ @DisplayName("IllegalArgumentException -> 400 BAD_REQUEST")
+ void illegalArgument_400() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(req, new IllegalArgumentException("bad"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(bodyOf(resp))
+ .containsEntry("error", "IllegalArgumentException")
+ .containsEntry("message", "bad")
+ .containsEntry("status", 400);
+ }
+
+ @Test
+ @DisplayName("AccessDeniedException -> 403 FORBIDDEN")
+ void accessDenied_403() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(
+ req,
+ new org.springframework.security.access.AccessDeniedException("nope"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ }
+
+ @Test
+ @DisplayName("UsernameNotFoundException -> 401 UNAUTHORIZED")
+ void usernameNotFound_401() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(
+ req,
+ new org.springframework.security.core.userdetails
+ .UsernameNotFoundException("who"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ }
+
+ @Test
+ @DisplayName("UnsupportedOperationException -> 501 NOT_IMPLEMENTED")
+ void unsupported_501() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(req, new UnsupportedOperationException("later"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_IMPLEMENTED);
+ }
+
+ @Test
+ @DisplayName("unknown exception with no message clue -> 500 INTERNAL_SERVER_ERROR")
+ void unknown_500() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("boom"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+
+ @Test
+ @DisplayName("message containing 'not found' -> 404 NOT_FOUND")
+ void messageNotFound_404() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(req, new RuntimeException("Resource not found here"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("message containing 'validation' -> 400 BAD_REQUEST")
+ void messageValidation_400() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(req, new RuntimeException("Validation of input failed"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ @DisplayName("message containing 'invalid parameter' -> 400 BAD_REQUEST")
+ void messageInvalidParameter_400() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp =
+ advice.handleThrowable(req, new RuntimeException("invalid parameter: x"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ @DisplayName("null message falls back to 'An error occurred' and 500")
+ void nullMessage_default() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException());
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(bodyOf(resp)).containsEntry("message", "An error occurred");
+ }
+ }
+
+ // --- no credit handling (gates closed) ------------------------------------------------------
+
+ @Nested
+ @DisplayName("credit handling gate is closed -> no consumption")
+ class GateClosed {
+
+ @Test
+ @DisplayName("request not eligible: no error tracking, no consumption")
+ void notEligible_noConsumption() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ // ATTR_ELIGIBLE absent
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("eligible but already charged and unauthenticated: no consumption, no header")
+ void alreadyCharged_unauthenticated_noHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
+ // no SecurityContext authentication
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName(
+ "eligible, null api key: error tracking is skipped entirely (apiKey != null guard)")
+ void eligibleNullApiKey_noTracking() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setRequestURI(URI);
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
+ // no ATTR_APIKEY -> apiKey is null
+
+ advice.handleThrowable(req, new RuntimeException("x"));
+
+ verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("eligible with api key but tracking says do NOT consume: no charge")
+ void trackingSaysNo_noConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(false);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ verify(errorTrackingService)
+ .recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), eq(500));
+ verifyNoInteractions(creditService, teamCreditService);
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ }
+ }
+
+ // --- individual (waterfall) consumption -----------------------------------------------------
+
+ @Nested
+ @DisplayName("individual credit consumption (no team)")
+ class IndividualConsumption {
+
+ @Test
+ @DisplayName("waterfall success: marks charged, increments counter, sets both headers")
+ void waterfallSuccess_chargesAndHeaders() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3));
+ req.setAttribute(ATTR_IS_API, Boolean.TRUE);
+ User u = user("alice");
+ authenticate(u);
+
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(u, 3, true))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(42);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(counter()).isEqualTo(1.0d);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("CYCLE_CREDITS");
+ verify(creditService).consumeCreditWithWaterfall(u, 3, true);
+ verify(teamCreditService, never()).consumeCredit(any(), anyInt());
+ }
+
+ @Test
+ @DisplayName("resource weight absent defaults credit amount to 1")
+ void weightAbsent_defaultsToOne() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setRequestURI(URI);
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
+ req.setAttribute(ATTR_APIKEY, API_KEY);
+ // no resource weight, no IS_API_REQUEST -> isApiRequestFlag false
+ User u = user("bob");
+ authenticate(u);
+
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(0);
+
+ advice.handleThrowable(req, new RuntimeException("x"));
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ }
+
+ @Test
+ @DisplayName("waterfall failure: not charged, counter stays zero, no headers")
+ void waterfallFailure_notCharged() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("carol");
+ authenticate(u);
+
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"));
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
+ }
+
+ @Test
+ @DisplayName("success but negative remaining: charged + source header, no remaining header")
+ void successNegativeRemaining_noRemainingHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("dave");
+ authenticate(u);
+
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(-1);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(counter()).isEqualTo(1.0d);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source"))
+ .isEqualTo("METERED_SUBSCRIPTION");
+ }
+
+ @Test
+ @DisplayName("success with null source: charged, remaining header set, no source header")
+ void successNullSource_noSourceHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("erin");
+ authenticate(u);
+
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ // success=true but source=null (unusual but defensively handled by the advice)
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(new CreditConsumptionResult(true, null, "ok"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(7);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("7");
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
+ }
+
+ @Test
+ @DisplayName("personal team is treated as no team -> waterfall, not team pool")
+ void personalTeam_usesWaterfall() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("frank");
+ u.setTeam(team(99L));
+ authenticate(u);
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(5);
+
+ advice.handleThrowable(req, new RuntimeException("x"));
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ verify(teamCreditService, never()).consumeCredit(any(), anyInt());
+ }
+ }
+
+ // --- team consumption -----------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("team credit consumption (non-personal team)")
+ class TeamConsumption {
+
+ @Test
+ @DisplayName("non-personal team success: consumes from team pool, source TEAM_CREDITS")
+ void teamSuccess_consumesTeamPool() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2));
+ User u = user("gina");
+ u.setTeam(team(77L));
+ authenticate(u);
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(teamCreditService.consumeCredit(77L, 2)).thenReturn(true);
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(100);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(counter()).isEqualTo(1.0d);
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("100");
+ verify(teamCreditService).consumeCredit(77L, 2);
+ verify(creditService, never())
+ .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
+ }
+
+ @Test
+ @DisplayName("non-personal team failure: not charged, counter stays zero")
+ void teamFailure_notCharged() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("hank");
+ u.setTeam(team(55L));
+ authenticate(u);
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(teamCreditService.consumeCredit(55L, 1)).thenReturn(false);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
+ verify(creditService, never())
+ .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
+ }
+ }
+
+ // --- user resolution edge cases -------------------------------------------------------------
+
+ @Nested
+ @DisplayName("user resolution edge cases (tracking says consume)")
+ class UserResolution {
+
+ @Test
+ @DisplayName("no authentication: getCurrentUser throws, user null -> no consumption")
+ void noAuth_userNull_noConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ // no SecurityContext authentication -> AuthenticationUtils throws SecurityException
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ verifyNoInteractions(creditService, teamCreditService);
+ }
+ }
+
+ // --- already-charged header path ------------------------------------------------------------
+
+ @Nested
+ @DisplayName("already-charged path sets remaining header when authenticated")
+ class AlreadyCharged {
+
+ @Test
+ @DisplayName(
+ "already charged + authenticated + non-negative remaining: header set, no consumption")
+ void alreadyCharged_authenticated_setsHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
+ User u = user("ian");
+ authenticate(u);
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(9);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("9");
+ // Already-charged branch never records an error or consumes again.
+ verifyNoInteractions(errorTrackingService, teamCreditService);
+ verify(creditService, never())
+ .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("already charged + authenticated + negative remaining: no header")
+ void alreadyCharged_negativeRemaining_noHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
+ User u = user("jane");
+ authenticate(u);
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(-1);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
+ }
+
+ @Test
+ @DisplayName("already charged: header lookup throwing is swallowed (no propagation)")
+ void alreadyCharged_headerLookupThrows_swallowed() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
+ User u = user("kyle");
+ authenticate(u);
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenThrow(new RuntimeException("header boom"));
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ // Must not propagate; status still computed and no remaining header set.
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
+ }
+ }
+
+ // --- SSE response shaping --------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("SSE response shaping")
+ class SseShaping {
+
+ @Test
+ @DisplayName("Accept text/event-stream: body is SSE framed text with event: error")
+ void acceptHeader_producesSseBody() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE);
+
+ ResponseEntity resp =
+ advice.handleThrowable(req, new IllegalArgumentException("bad"));
+
+ assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
+ assertThat(resp.getBody()).isInstanceOf(String.class);
+ String sse = (String) resp.getBody();
+ assertThat(sse).startsWith("event: error\ndata: ").endsWith("\n\n");
+ assertThat(sse).contains("\"error\":\"IllegalArgumentException\"");
+ assertThat(sse).contains("\"status\":400");
+ }
+
+ @Test
+ @DisplayName("Content-Type text/event-stream also triggers SSE framing")
+ void contentTypeHeader_producesSseBody() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("boom"));
+
+ assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
+ assertThat((String) resp.getBody()).startsWith("event: error\ndata: ");
+ }
+
+ @Test
+ @DisplayName("non-SSE request returns the Map body, not SSE text")
+ void nonSse_returnsMapBody() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("boom"));
+
+ assertThat(resp.getBody()).isInstanceOf(Map.class);
+ assertThat(resp.getHeaders().getContentType()).isNull();
+ }
+
+ @Test
+ @DisplayName(
+ "SSE framing carries through after a successful team charge (headers + SSE body)")
+ void sseWithTeamCharge_headersAndSseBody() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE);
+ User u = user("liz");
+ u.setTeam(team(33L));
+ authenticate(u);
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(teamCreditService.consumeCredit(33L, 1)).thenReturn(true);
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(8);
+
+ ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x"));
+
+ assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
+ assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
+ assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("8");
+ assertThat((String) resp.getBody()).startsWith("event: error\ndata: ");
+ }
+ }
+
+ // --- counter accumulation across calls -------------------------------------------------------
+
+ @Test
+ @DisplayName("counter accumulates across multiple successful charges")
+ void counterAccumulates() {
+ User u = user("mike");
+ authenticate(u);
+ when(errorTrackingService.recordErrorAndShouldConsumeCredit(
+ eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
+ .thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(eq(u), eq(1), eq(false)))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(3);
+
+ advice.handleThrowable(eligibleRequest(), new RuntimeException("a"));
+ advice.handleThrowable(eligibleRequest(), new RuntimeException("b"));
+
+ assertThat(counter()).isEqualTo(2.0d);
+ verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false);
+ }
+
+ @Test
+ @DisplayName("ErrorResponse value holder wires its fields verbatim")
+ void errorResponseHolder() {
+ CreditErrorAdvice.ErrorResponse er =
+ new CreditErrorAdvice.ErrorResponse("Boom", "it broke", 500);
+
+ assertThat(er.error).isEqualTo("Boom");
+ assertThat(er.message).isEqualTo("it broke");
+ assertThat(er.status).isEqualTo(500);
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java b/app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java
new file mode 100644
index 000000000..f89d841b4
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java
@@ -0,0 +1,679 @@
+package stirling.software.saas.interceptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.CreditConsumptionResult;
+import stirling.software.saas.service.CreditService;
+import stirling.software.saas.service.SaasTeamExtensionService;
+import stirling.software.saas.service.TeamCreditService;
+import stirling.software.saas.util.CreditHeaderUtils;
+
+/**
+ * Unit tests for {@link CreditSuccessAdvice}.
+ *
+ * The advice is a {@code @RestControllerAdvice} {@code ResponseBodyAdvice} that, on a successful
+ * (status < 400) response previously flagged credit-eligible (and not already charged), consumes
+ * a credit either from the team pool (non-personal team) or via the individual waterfall, then sets
+ * {@code X-Credits-Remaining} / {@code X-Credit-Source} headers and increments a {@code
+ * credits.consumed} counter. Collaborators are mocked; the {@link MeterRegistry} is a real {@link
+ * SimpleMeterRegistry} so the counter is exercised.
+ *
+ *
{@link ServerHttpRequest}/response are constructed by wrapping {@link MockHttpServletRequest}
+ * and {@link MockHttpServletResponse} in {@link ServletServerHttpRequest}/{@link
+ * ServletServerHttpResponse} so the advice's {@code instanceof} unwrapping and status read work
+ * against the mock servlet objects. {@code beforeBodyWrite}'s {@code MethodParameter}, {@code
+ * MediaType} and converter type arguments are unused by the charging logic, so {@code null} is
+ * passed for them.
+ *
+ *
Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code
+ * UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link
+ * User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code
+ * instanceof User} without touching the repository. The authorities on that token drive the
+ * limited-API-user check (which the advice reads from the authentication, not the user).
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CreditSuccessAdviceTest {
+
+ private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
+ private static final String ATTR_APIKEY = "CREDIT_API_KEY";
+ private static final String ATTR_CHARGED = "CREDIT_CHARGED";
+ private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
+ private static final String ATTR_IS_API = "IS_API_REQUEST";
+
+ private static final String API_KEY = "apikey-abcdefgh";
+
+ @Mock private CreditService creditService;
+ @Mock private TeamCreditService teamCreditService;
+ @Mock private UserRepository userRepository;
+ @Mock private SaasTeamExtensionService saasTeamExtensionService;
+ @Mock private CreditHeaderUtils creditHeaderUtils;
+
+ private MeterRegistry meterRegistry;
+ private CreditSuccessAdvice advice;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ advice =
+ new CreditSuccessAdvice(
+ creditService,
+ teamCreditService,
+ userRepository,
+ saasTeamExtensionService,
+ creditHeaderUtils,
+ meterRegistry);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // --- helpers --------------------------------------------------------------------------------
+
+ private static User user(String username) {
+ User u = new User();
+ u.setUsername(username);
+ return u;
+ }
+
+ private static Team team(Long id) {
+ Team t = new Team();
+ t.setId(id);
+ return t;
+ }
+
+ /** Authenticate with the default ROLE_USER authority (not a limited-API user). */
+ private void authenticate(User user) {
+ authenticate(user, "ROLE_USER");
+ }
+
+ /** Put the user on the SecurityContext as an authenticated principal with given authorities. */
+ private void authenticate(User user, String... authorities) {
+ var grants = java.util.Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList();
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken(user, null, grants);
+ SecurityContextHolder.getContext().setAuthentication(token);
+ }
+
+ /** Base request: credit-eligible with an api key, resource weight 1, not charged. */
+ private MockHttpServletRequest eligibleRequest() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
+ req.setAttribute(ATTR_APIKEY, API_KEY);
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
+ return req;
+ }
+
+ /** Holder so a test can read back both the returned body and the underlying servlet objects. */
+ private static final class Exchange {
+ final MockHttpServletRequest servletReq;
+ final MockHttpServletResponse servletResp;
+ final ServletServerHttpResponse response;
+
+ Exchange(MockHttpServletRequest servletReq, MockHttpServletResponse servletResp) {
+ this.servletReq = servletReq;
+ this.servletResp = servletResp;
+ this.response = new ServletServerHttpResponse(servletResp);
+ }
+
+ String header(String name) {
+ // Read from the live ServerHttpResponse headers the advice wrote to.
+ return response.getHeaders().getFirst(name);
+ }
+ }
+
+ /**
+ * Invoke beforeBodyWrite against the given servlet request/response and return the exchange.
+ */
+ private Object invoke(Exchange ex, Object body) {
+ ServletServerHttpRequest request = new ServletServerHttpRequest(ex.servletReq);
+ return advice.beforeBodyWrite(body, null, null, null, request, ex.response);
+ }
+
+ private Object invoke(MockHttpServletRequest servletReq, Object body) {
+ return invoke(new Exchange(servletReq, new MockHttpServletResponse()), body);
+ }
+
+ private double counter() {
+ return meterRegistry.get("credits.consumed").counter().count();
+ }
+
+ // --- supports() -----------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("supports() returns true for any return type / converter")
+ void supports_alwaysTrue() {
+ assertThat(advice.supports(null, null)).isTrue();
+ }
+
+ // --- gates that short-circuit (body returned unchanged, nothing consumed) --------------------
+
+ @Nested
+ @DisplayName("short-circuit gates -> body returned unchanged, no consumption")
+ class Gates {
+
+ @Test
+ @DisplayName("non-servlet request: returns body untouched, no interactions")
+ void nonServletRequest_returnsBody() {
+ ServerHttpRequest notServlet = org.mockito.Mockito.mock(ServerHttpRequest.class);
+ ServletServerHttpResponse resp =
+ new ServletServerHttpResponse(new MockHttpServletResponse());
+
+ Object body = "BODY";
+ Object out = advice.beforeBodyWrite(body, null, null, null, notServlet, resp);
+
+ assertThat(out).isSameAs(body);
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("not eligible (attribute absent): no consumption")
+ void notEligible_noConsumption() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setAttribute(ATTR_APIKEY, API_KEY);
+
+ Object out = invoke(req, "BODY");
+
+ assertThat(out).isEqualTo("BODY");
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("eligible attribute not Boolean.TRUE (e.g. Boolean.FALSE): no consumption")
+ void eligibleFalse_noConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.FALSE);
+ authenticate(user("x"));
+
+ invoke(req, "BODY");
+
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("already charged (CREDIT_CHARGED set): no second consumption")
+ void alreadyCharged_noConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
+ authenticate(user("x"));
+
+ invoke(req, "BODY");
+
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("error status >= 400 on response: skip consumption, error advice decides")
+ void errorStatus_skipsConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ authenticate(user("x"));
+ MockHttpServletResponse servletResp = new MockHttpServletResponse();
+ servletResp.setStatus(500);
+ Exchange ex = new Exchange(req, servletResp);
+
+ Object out = invoke(ex, "BODY");
+
+ assertThat(out).isEqualTo("BODY");
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("status exactly 400 is treated as error -> skip")
+ void status400_skipsConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ authenticate(user("x"));
+ MockHttpServletResponse servletResp = new MockHttpServletResponse();
+ servletResp.setStatus(400);
+
+ invoke(new Exchange(req, servletResp), "BODY");
+
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+
+ @Test
+ @DisplayName("status 399 (just below 400) still consumes")
+ void status399_stillConsumes() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("edge");
+ authenticate(u);
+ MockHttpServletResponse servletResp = new MockHttpServletResponse();
+ servletResp.setStatus(399);
+ Exchange ex = new Exchange(req, servletResp);
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(5);
+
+ invoke(ex, "BODY");
+
+ assertThat(counter()).isEqualTo(1.0d);
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ }
+
+ @Test
+ @DisplayName("eligible but apiKey attribute absent: no consumption (apiKey != null guard)")
+ void nullApiKey_noConsumption() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
+ // no ATTR_APIKEY -> apiKey null
+ authenticate(user("x"));
+
+ invoke(req, "BODY");
+
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ assertThat(counter()).isZero();
+ }
+ }
+
+ // --- individual (waterfall) consumption -----------------------------------------------------
+
+ @Nested
+ @DisplayName("individual credit consumption (no team)")
+ class IndividualConsumption {
+
+ @Test
+ @DisplayName("waterfall success: marks charged, increments counter, sets both headers")
+ void waterfallSuccess_chargesAndHeaders() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3));
+ req.setAttribute(ATTR_IS_API, Boolean.TRUE);
+ User u = user("alice");
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 3, true))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(42);
+
+ Object out = invoke(ex, "BODY");
+
+ assertThat(out).isEqualTo("BODY");
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(counter()).isEqualTo(1.0d);
+ assertThat(ex.header("X-Credits-Remaining")).isEqualTo("42");
+ assertThat(ex.header("X-Credit-Source")).isEqualTo("CYCLE_CREDITS");
+ verify(creditService).consumeCreditWithWaterfall(u, 3, true);
+ verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
+ }
+
+ @Test
+ @DisplayName("resource weight absent defaults credit amount to 1, IS_API absent -> false")
+ void weightAbsent_defaultsToOne() {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
+ req.setAttribute(ATTR_APIKEY, API_KEY);
+ // no resource weight, no IS_API_REQUEST -> isApiRequestFlag false
+ User u = user("bob");
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(0);
+
+ invoke(ex, "BODY");
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ // remaining 0 is non-negative -> header set
+ assertThat(ex.header("X-Credits-Remaining")).isEqualTo("0");
+ assertThat(ex.header("X-Credit-Source")).isEqualTo("BOUGHT_CREDITS");
+ }
+
+ @Test
+ @DisplayName("IS_API_REQUEST=false is passed through as false to the waterfall")
+ void isApiFalse_passedThrough() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_IS_API, Boolean.FALSE);
+ User u = user("ivy");
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(3);
+
+ invoke(ex, "BODY");
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ }
+
+ @Test
+ @DisplayName(
+ "waterfall failure (insufficient credits): not charged, counter zero, no headers")
+ void waterfallFailure_notCharged() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("carol");
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"));
+
+ invoke(ex, "BODY");
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ assertThat(ex.header("X-Credits-Remaining")).isNull();
+ assertThat(ex.header("X-Credit-Source")).isNull();
+ // header utils is only consulted after a successful charge
+ verifyNoInteractions(creditHeaderUtils);
+ }
+
+ @Test
+ @DisplayName("success but negative remaining: charged + source header, no remaining header")
+ void successNegativeRemaining_noRemainingHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("dave");
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(-1);
+
+ invoke(ex, "BODY");
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(counter()).isEqualTo(1.0d);
+ assertThat(ex.header("X-Credits-Remaining")).isNull();
+ assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION");
+ }
+
+ @Test
+ @DisplayName("success with null source: charged, remaining header set, no source header")
+ void successNullSource_noSourceHeader() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("erin");
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ // success=true but source=null (defensively handled by the advice)
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(new CreditConsumptionResult(true, null, "ok"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(7);
+
+ invoke(ex, "BODY");
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(ex.header("X-Credits-Remaining")).isEqualTo("7");
+ assertThat(ex.header("X-Credit-Source")).isNull();
+ }
+
+ @Test
+ @DisplayName("user with personal team is treated as no team -> waterfall, not team pool")
+ void personalTeam_usesWaterfall() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("frank");
+ u.setTeam(team(99L));
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(5);
+
+ invoke(ex, "BODY");
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
+ }
+ }
+
+ // --- limited-API users always use personal credits ------------------------------------------
+
+ @Nested
+ @DisplayName("limited-API users always use personal credits (never team pool)")
+ class LimitedApiUsers {
+
+ @Test
+ @DisplayName("ROLE_LIMITED_API_USER in a non-personal team still uses the waterfall")
+ void limitedApiUser_usesWaterfall() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("lim");
+ u.setTeam(team(77L));
+ authenticate(u, "ROLE_LIMITED_API_USER");
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(2);
+
+ invoke(ex, "BODY");
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
+ // isPersonal short-circuited by the limited-API check; team service untouched
+ verify(saasTeamExtensionService, never()).isPersonal(any());
+ }
+
+ @Test
+ @DisplayName("ROLE_EXTRA_LIMITED_API_USER in a non-personal team still uses the waterfall")
+ void extraLimitedApiUser_usesWaterfall() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("xlim");
+ u.setTeam(team(88L));
+ authenticate(u, "ROLE_EXTRA_LIMITED_API_USER");
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(1);
+
+ invoke(ex, "BODY");
+
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
+ }
+ }
+
+ // --- team consumption -----------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("team credit consumption (non-personal team)")
+ class TeamConsumption {
+
+ @Test
+ @DisplayName("non-personal team success: consumes from team pool, sets source header")
+ void teamSuccess_consumesTeamPool() {
+ MockHttpServletRequest req = eligibleRequest();
+ req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2));
+ User u = user("gina");
+ u.setTeam(team(77L));
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.consumeCreditWithWaterfall(77L, 2))
+ .thenReturn(CreditConsumptionResult.success("TEAM_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(100);
+
+ Object out = invoke(ex, "BODY");
+
+ assertThat(out).isEqualTo("BODY");
+ assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
+ assertThat(counter()).isEqualTo(1.0d);
+ assertThat(ex.header("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
+ assertThat(ex.header("X-Credits-Remaining")).isEqualTo("100");
+ verify(teamCreditService).consumeCreditWithWaterfall(77L, 2);
+ verify(creditService, never())
+ .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
+ }
+
+ @Test
+ @DisplayName("non-personal team success via leader overage source is propagated")
+ void teamSuccess_overageSourcePropagated() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("greg");
+ u.setTeam(team(12L));
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.consumeCreditWithWaterfall(12L, 1))
+ .thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(0);
+
+ invoke(ex, "BODY");
+
+ assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION");
+ }
+
+ @Test
+ @DisplayName("non-personal team failure: not charged, counter zero, no headers")
+ void teamFailure_notCharged() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("hank");
+ u.setTeam(team(55L));
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.consumeCreditWithWaterfall(55L, 1))
+ .thenReturn(
+ CreditConsumptionResult.failure("TEAM_CREDITS_EXHAUSTED_NO_OVERAGE"));
+
+ invoke(ex, "BODY");
+
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ assertThat(ex.header("X-Credits-Remaining")).isNull();
+ assertThat(ex.header("X-Credit-Source")).isNull();
+ verify(creditService, never())
+ .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
+ verifyNoInteractions(creditHeaderUtils);
+ }
+
+ @Test
+ @DisplayName("team with null id is treated as no team -> waterfall used")
+ void teamNullId_usesWaterfall() {
+ MockHttpServletRequest req = eligibleRequest();
+ User u = user("nina");
+ u.setTeam(team(null)); // non-personal (isPersonal false) but id null
+ authenticate(u);
+ Exchange ex = new Exchange(req, new MockHttpServletResponse());
+
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(4);
+
+ invoke(ex, "BODY");
+
+ // targetTeamId resolves to null (team id null) -> individual waterfall
+ verify(creditService).consumeCreditWithWaterfall(u, 1, false);
+ verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
+ }
+ }
+
+ // --- user resolution edge cases -------------------------------------------------------------
+
+ @Nested
+ @DisplayName("user resolution edge cases")
+ class UserResolution {
+
+ @Test
+ @DisplayName("no authentication: getCurrentUser throws, user null -> no consumption")
+ void noAuth_userNull_noConsumption() {
+ MockHttpServletRequest req = eligibleRequest();
+ // no SecurityContext authentication -> AuthenticationUtils throws SecurityException
+
+ Object out = invoke(req, "BODY");
+
+ assertThat(out).isEqualTo("BODY");
+ assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
+ assertThat(counter()).isZero();
+ verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
+ }
+ }
+
+ // --- counter accumulation -------------------------------------------------------------------
+
+ @Test
+ @DisplayName("counter accumulates across multiple successful charges")
+ void counterAccumulates() {
+ User u = user("mike");
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(3);
+
+ // fresh request each time so ATTR_CHARGED from a prior call doesn't block the next
+ authenticate(u);
+ invoke(eligibleRequest(), "A");
+ invoke(eligibleRequest(), "B");
+
+ assertThat(counter()).isEqualTo(2.0d);
+ verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false);
+ }
+
+ @Test
+ @DisplayName("body is always returned verbatim (including null) regardless of charging")
+ void bodyReturnedVerbatim() {
+ User u = user("nora");
+ authenticate(u);
+ when(creditService.consumeCreditWithWaterfall(u, 1, false))
+ .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
+ when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
+ .thenReturn(1);
+
+ Object out = invoke(eligibleRequest(), null);
+
+ assertThat(out).isNull();
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java
new file mode 100644
index 000000000..d56fe7e06
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java
@@ -0,0 +1,806 @@
+package stirling.software.saas.interceptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.method.HandlerMethod;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+import stirling.software.common.annotations.AutoJobPostMapping;
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
+import stirling.software.proprietary.security.model.Authority;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.config.CreditsProperties;
+import stirling.software.saas.model.TeamCredit;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.model.UserCredit;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.service.CreditService;
+import stirling.software.saas.service.ErrorTrackingService;
+import stirling.software.saas.service.SaasTeamExtensionService;
+import stirling.software.saas.service.SaasUserExtensionService;
+import stirling.software.saas.service.TeamCreditService;
+
+/**
+ * Pure-Mockito unit tests for {@link UnifiedCreditInterceptor}. No Spring context: request/response
+ * are Spring's mock servlet objects, the SecurityContext is populated with real token types
+ * (matching the sibling {@code PaygChargeInterceptorTest} house style) and every collaborator is a
+ * Mockito mock. A real {@link SimpleMeterRegistry} backs the counters/timer so increments can be
+ * asserted directly.
+ */
+class UnifiedCreditInterceptorTest {
+
+ private CreditService creditService;
+ private ErrorTrackingService errorTrackingService;
+ private CreditsProperties creditsProperties;
+ private UserRepository userRepository;
+ private TeamCreditService teamCreditService;
+ private TeamMembershipRepository membershipRepository;
+ private SaasUserExtensionService saasUserExtensionService;
+ private SaasTeamExtensionService saasTeamExtensionService;
+ private MeterRegistry meterRegistry;
+ private UnifiedCreditInterceptor interceptor;
+
+ @BeforeEach
+ void setUp() {
+ creditService = Mockito.mock(CreditService.class);
+ errorTrackingService = Mockito.mock(ErrorTrackingService.class);
+ creditsProperties = new CreditsProperties();
+ userRepository = Mockito.mock(UserRepository.class);
+ teamCreditService = Mockito.mock(TeamCreditService.class);
+ membershipRepository = Mockito.mock(TeamMembershipRepository.class);
+ saasUserExtensionService = Mockito.mock(SaasUserExtensionService.class);
+ saasTeamExtensionService = Mockito.mock(SaasTeamExtensionService.class);
+ meterRegistry = new SimpleMeterRegistry();
+ interceptor =
+ new UnifiedCreditInterceptor(
+ creditService,
+ errorTrackingService,
+ creditsProperties,
+ userRepository,
+ teamCreditService,
+ membershipRepository,
+ saasUserExtensionService,
+ saasTeamExtensionService,
+ meterRegistry);
+ SecurityContextHolder.clearContext();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // --- gate short-circuits ---------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("preHandle gate / short-circuit branches")
+ class GateBranches {
+
+ @Test
+ @DisplayName("credits disabled allows request without touching any collaborator")
+ void creditsDisabled_allowsAndSkipsValidation() throws Exception {
+ creditsProperties.setEnabled(false);
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ verifyNoInteractions(creditService, teamCreditService, userRepository);
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
+ }
+
+ @Test
+ @DisplayName("non-HandlerMethod handler is out of scope and allowed")
+ void plainHandlerObject_isAllowed() throws Exception {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, new Object());
+
+ assertThat(cont).isTrue();
+ verifyNoInteractions(creditService, teamCreditService);
+ }
+
+ @Test
+ @DisplayName("HandlerMethod without @AutoJobPostMapping is out of scope and allowed")
+ void handlerWithoutAnnotation_isAllowed() throws Exception {
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForPlain());
+
+ assertThat(cont).isTrue();
+ verifyNoInteractions(creditService, teamCreditService);
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
+ }
+ }
+
+ // --- authentication gating -------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("authentication blocking")
+ class AuthBlocking {
+
+ @Test
+ @DisplayName("null authentication is blocked with 401")
+ void nullAuth_blockedWith401() throws Exception {
+ SecurityContextHolder.clearContext();
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(401);
+ assertThat(res.getContentType()).isEqualTo("application/json");
+ assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED");
+ verifyNoInteractions(creditService, teamCreditService);
+ }
+
+ @Test
+ @DisplayName("unauthenticated token (isAuthenticated=false) is blocked with 401")
+ void unauthenticatedToken_blockedWith401() throws Exception {
+ // 2-arg ctor leaves isAuthenticated()=false, so it falls through to the security block.
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken("someone", "creds"));
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(401);
+ assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED");
+ }
+ }
+
+ // --- JWT lookup / role bypass ----------------------------------------------------------------
+
+ @Nested
+ @DisplayName("JWT authentication: lookup, role bypass and bad identifiers")
+ class JwtLookupAndBypass {
+
+ @Test
+ @DisplayName("JWT user not found in repository returns 500 USER_NOT_FOUND")
+ void jwtUserMissing_returns500() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.empty());
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(500);
+ assertThat(res.getContentAsString()).contains("USER_NOT_FOUND");
+ verify(creditService, never()).getOrCreateUserCredits(any());
+ }
+
+ @Test
+ @DisplayName("JWT with non-UUID identifier returns 400 INVALID_USER_ID")
+ void jwtInvalidSupabaseIdFormat_returns400() throws Exception {
+ // auth.getName() is not a UUID → UUID.fromString throws IllegalArgumentException.
+ authenticateJwt("not-a-uuid", "ROLE_USER");
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(400);
+ assertThat(res.getContentAsString()).contains("INVALID_USER_ID");
+ verify(creditService, never()).getOrCreateUserCredits(any());
+ }
+
+ @Test
+ @DisplayName("admin JWT user bypasses credit validation and bumps jwt_bypass counter")
+ void adminJwtUser_bypassesValidation() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_ADMIN");
+ User admin = makeUser(1L, null, "ROLE_ADMIN");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(admin));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
+ assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
+ .isEqualTo(1.0);
+ verify(creditService, never()).getOrCreateUserCredits(any());
+ }
+
+ @Test
+ @DisplayName("internal backend-API JWT user bypasses credit validation")
+ void internalBackendApiUser_bypassesValidation() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "STIRLING-PDF-BACKEND-API-USER");
+ User internal = makeUser(2L, null, "STIRLING-PDF-BACKEND-API-USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(internal));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
+ .isEqualTo(1.0);
+ verify(creditService, never()).getOrCreateUserCredits(any());
+ }
+
+ @Test
+ @DisplayName("pro JWT user is NOT bypassed - still flows into waterfall credit checks")
+ void proJwtUser_isSubjectToCreditChecks() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_PRO_USER");
+ User pro = makeUser(3L, null, "ROLE_PRO_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(pro));
+ when(creditService.getOrCreateUserCredits(pro)).thenReturn(userCredit(100));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ // Pro is not bypassed; it consumed the credit-check path so the eligible flag is set.
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
+ .isEqualTo(0.0);
+ verify(creditService).getOrCreateUserCredits(pro);
+ }
+ }
+
+ // --- JWT personal-credit path ----------------------------------------------------------------
+
+ @Nested
+ @DisplayName("JWT personal-credit path")
+ class JwtPersonalCredits {
+
+ @Test
+ @DisplayName("sufficient personal credits passes and marks request eligible")
+ void sufficientPersonalCredits_passes() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ User user = makeUser(10L, null, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(50));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ // JWT credit identifier is the Supabase id, weight clamps to 1 (default sentinel).
+ assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo(supabaseId);
+ assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(1);
+ assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(false);
+ assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(false);
+ assertThat(meterRegistry.counter("credits.validation.checked").count()).isEqualTo(1.0);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName("insufficient personal credits without metered billing is rejected with 429")
+ void insufficientPersonalCredits_rejectedWith429() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ User user = makeUser(11L, null, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0));
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(429);
+ assertThat(res.getContentAsString()).contains("INSUFFICIENT_CREDITS");
+ // Personal (not team) message wording.
+ assertThat(res.getContentAsString()).contains("Insufficient API credits");
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
+ assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0);
+ }
+
+ @Test
+ @DisplayName(
+ "insufficient personal credits but metered billing enabled proceeds on overage")
+ void insufficientPersonalCredits_withMeteredBilling_proceeds() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ User user = makeUser(12L, null, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0));
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(0.0);
+ }
+ }
+
+ // --- JWT team-credit path --------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("JWT team-credit path")
+ class JwtTeamCredits {
+
+ @Test
+ @DisplayName("non-personal team with sufficient team credits passes")
+ void teamCreditsSufficient_passes() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ Team team = makeTeam(500L);
+ User user = makeUser(20L, team, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(teamCreditService.getTeamCredits(500L)).thenReturn(Optional.of(teamCredit(80)));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ verify(teamCreditService).getTeamCredits(500L);
+ verify(creditService, never()).getOrCreateUserCredits(any());
+ }
+
+ @Test
+ @DisplayName("non-personal team with no credits but leader metered billing proceeds")
+ void teamCreditsExhausted_leaderMetered_proceeds() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ Team team = makeTeam(501L);
+ User user = makeUser(21L, team, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(teamCreditService.getTeamCredits(501L)).thenReturn(Optional.of(teamCredit(0)));
+
+ // Leader has metered billing → overage allowed even with zero team credits.
+ User leader = makeUser(99L, team, "ROLE_USER");
+ TeamMembership leaderMembership = new TeamMembership();
+ leaderMembership.setUser(leader);
+ leaderMembership.setRole(TeamRole.LEADER);
+ when(membershipRepository.findByTeamIdAndRole(501L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ }
+
+ @Test
+ @DisplayName(
+ "non-personal team with no credits and no leader metered is rejected (team msg)")
+ void teamCreditsExhausted_noLeaderMetered_rejectedWithTeamMessage() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ Team team = makeTeam(502L);
+ User user = makeUser(22L, team, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(teamCreditService.getTeamCredits(502L)).thenReturn(Optional.of(teamCredit(0)));
+ when(membershipRepository.findByTeamIdAndRole(502L, TeamRole.LEADER))
+ .thenReturn(List.of());
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(429);
+ assertThat(res.getContentAsString()).contains("Insufficient team credits");
+ assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0);
+ }
+
+ @Test
+ @DisplayName("personal team falls back to personal credits, not team credits")
+ void personalTeam_usesPersonalCredits() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ Team team = makeTeam(503L);
+ User user = makeUser(23L, team, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(true);
+ when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(10));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ verify(creditService).getOrCreateUserCredits(user);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName("limited-API JWT user in a team uses personal credits, never team credits")
+ void limitedApiUserInTeam_usesPersonalCredits() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_LIMITED_API_USER");
+ Team team = makeTeam(504L);
+ User user = makeUser(24L, team, "ROLE_LIMITED_API_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(5));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ verify(creditService).getOrCreateUserCredits(user);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ verify(saasTeamExtensionService, never()).isPersonal(any());
+ }
+
+ @Test
+ @DisplayName("leader-metered lookup that throws is swallowed and treated as not-metered")
+ void leaderMeteredLookupThrows_treatedAsNotMetered() throws Exception {
+ String supabaseId = UUID.randomUUID().toString();
+ authenticateJwt(supabaseId, "ROLE_USER");
+ Team team = makeTeam(505L);
+ User user = makeUser(25L, team, "ROLE_USER");
+ when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
+ .thenReturn(Optional.of(user));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(teamCreditService.getTeamCredits(505L)).thenReturn(Optional.of(teamCredit(0)));
+ when(membershipRepository.findByTeamIdAndRole(505L, TeamRole.LEADER))
+ .thenThrow(new RuntimeException("db down"));
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ // Exception in checkTeamLeaderMeteredBilling is caught → false → rejected, not a 500.
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(429);
+ }
+ }
+
+ // --- API key path ----------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("API key authentication path")
+ class ApiKeyPath {
+
+ @Test
+ @DisplayName("API key with sufficient credits passes and flags API request attributes")
+ void apiKeySufficientCredits_passes() throws Exception {
+ User user = makeUser(30L, null, "ROLE_API");
+ user.setApiKey("sk-abcd1234efgh5678");
+ authenticateApiKey(user, "sk-abcd1234efgh5678");
+ when(creditService.getUserCreditsByApiKey("sk-abcd1234efgh5678"))
+ .thenReturn(Optional.of(userCredit(20)));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo("sk-abcd1234efgh5678");
+ assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(true);
+ assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(true);
+ verify(creditService).getUserCreditsByApiKey("sk-abcd1234efgh5678");
+ verify(creditService, never()).getOrCreateUserCredits(any());
+ }
+
+ @Test
+ @DisplayName("API key with no credit row defaults to zero balance and is rejected")
+ void apiKeyNoCreditRow_rejectedWith429() throws Exception {
+ User user = makeUser(31L, null, "ROLE_API");
+ user.setApiKey("sk-zzzz0000zzzz0000");
+ authenticateApiKey(user, "sk-zzzz0000zzzz0000");
+ when(creditService.getUserCreditsByApiKey("sk-zzzz0000zzzz0000"))
+ .thenReturn(Optional.empty());
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(429);
+ // API key users always use personal credits → personal message even with a team absent.
+ assertThat(res.getContentAsString()).contains("Insufficient API credits");
+ }
+
+ @Test
+ @DisplayName("API key insufficient credits but metered billing enabled proceeds")
+ void apiKeyInsufficientCredits_withMeteredBilling_proceeds() throws Exception {
+ User user = makeUser(32L, null, "ROLE_API");
+ user.setApiKey("sk-meter0000meter0");
+ authenticateApiKey(user, "sk-meter0000meter0");
+ when(creditService.getUserCreditsByApiKey("sk-meter0000meter0"))
+ .thenReturn(Optional.of(userCredit(0)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ }
+ }
+
+ // --- resource weight clamping ----------------------------------------------------------------
+
+ @Nested
+ @DisplayName("resource weight clamping")
+ class ResourceWeightClamping {
+
+ @Test
+ @DisplayName("weight above 100 clamps to 100")
+ void weightAbove100_clampsTo100() throws Exception {
+ User user = makeUser(40L, null, "ROLE_API");
+ user.setApiKey("sk-clamp0000clamp00");
+ authenticateApiKey(user, "sk-clamp0000clamp00");
+ when(creditService.getUserCreditsByApiKey("sk-clamp0000clamp00"))
+ .thenReturn(Optional.of(userCredit(1000)));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(100);
+ }
+
+ @Test
+ @DisplayName("a weight-100 job is rejected when balance is below 100 (no rounding leak)")
+ void weight100_balance50_rejected() throws Exception {
+ User user = makeUser(41L, null, "ROLE_API");
+ user.setApiKey("sk-tight0000tight00");
+ authenticateApiKey(user, "sk-tight0000tight00");
+ when(creditService.getUserCreditsByApiKey("sk-tight0000tight00"))
+ .thenReturn(Optional.of(userCredit(50)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
+
+ assertThat(cont).isFalse();
+ assertThat(res.getStatus()).isEqualTo(429);
+ }
+
+ @Test
+ @DisplayName("exact balance == weight is sufficient (>= boundary)")
+ void exactBalanceEqualsWeight_passes() throws Exception {
+ User user = makeUser(42L, null, "ROLE_API");
+ user.setApiKey("sk-exact0000exact00");
+ authenticateApiKey(user, "sk-exact0000exact00");
+ // Heavy endpoint weight clamps to 100; exactly 100 credits is sufficient.
+ when(creditService.getUserCreditsByApiKey("sk-exact0000exact00"))
+ .thenReturn(Optional.of(userCredit(100)));
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ MockHttpServletResponse res = new MockHttpServletResponse();
+
+ boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
+
+ assertThat(cont).isTrue();
+ assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
+ }
+ }
+
+ // --- lifecycle no-ops ------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("postHandle / afterCompletion / afterConcurrentHandlingStarted are no-ops")
+ class LifecycleNoOps {
+
+ @Test
+ @DisplayName("postHandle does no spending and touches no collaborator")
+ void postHandle_isNoOp() throws Exception {
+ interceptor.postHandle(
+ new MockHttpServletRequest(),
+ new MockHttpServletResponse(),
+ handlerMethodForAuto(),
+ null);
+
+ verifyNoInteractions(
+ creditService, teamCreditService, errorTrackingService, userRepository);
+ }
+
+ @Test
+ @DisplayName("afterCompletion with no exception does no spending")
+ void afterCompletion_success_isNoOp() throws Exception {
+ interceptor.afterCompletion(
+ new MockHttpServletRequest(),
+ new MockHttpServletResponse(),
+ handlerMethodForAuto(),
+ null);
+
+ verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
+ }
+
+ @Test
+ @DisplayName("afterCompletion with an exception still does no spending")
+ void afterCompletion_withException_isNoOp() throws Exception {
+ interceptor.afterCompletion(
+ new MockHttpServletRequest(),
+ new MockHttpServletResponse(),
+ handlerMethodForAuto(),
+ new RuntimeException("boom"));
+
+ verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
+ }
+
+ @Test
+ @DisplayName("afterConcurrentHandlingStarted does no spending")
+ void afterConcurrentHandlingStarted_isNoOp() throws Exception {
+ interceptor.afterConcurrentHandlingStarted(
+ new MockHttpServletRequest(),
+ new MockHttpServletResponse(),
+ handlerMethodForAuto());
+
+ verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
+ }
+ }
+
+ // --- helpers ---------------------------------------------------------------------------------
+
+ private void authenticateJwt(String name, String role) {
+ // Not an EnhancedJwtAuthenticationToken, so extractSupabaseId falls back to auth.getName().
+ // 3-arg ctor → isAuthenticated()=true so the JWT branch is taken.
+ UsernamePasswordAuthenticationToken token =
+ new UsernamePasswordAuthenticationToken(
+ name, null, List.of(new SimpleGrantedAuthority(role)));
+ SecurityContextHolder.getContext().setAuthentication(token);
+ }
+
+ private void authenticateApiKey(User user, String apiKey) {
+ ApiKeyAuthenticationToken token =
+ new ApiKeyAuthenticationToken(
+ user, apiKey, List.of(new SimpleGrantedAuthority("ROLE_API")));
+ SecurityContextHolder.getContext().setAuthentication(token);
+ }
+
+ private static UserCredit userCredit(int cycleCredits) {
+ UserCredit c = new UserCredit();
+ c.setCycleCreditsRemaining(cycleCredits);
+ c.setBoughtCreditsRemaining(0);
+ return c;
+ }
+
+ private static TeamCredit teamCredit(int cycleCredits) {
+ TeamCredit c = new TeamCredit();
+ c.setCycleCreditsRemaining(cycleCredits);
+ c.setBoughtCreditsRemaining(0);
+ return c;
+ }
+
+ private static Team makeTeam(Long id) {
+ Team team = new Team();
+ team.setId(id);
+ return team;
+ }
+
+ private static User makeUser(Long id, Team team, String role) {
+ User user = new User();
+ try {
+ java.lang.reflect.Field idField = User.class.getDeclaredField("id");
+ idField.setAccessible(true);
+ idField.set(user, id);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException(e);
+ }
+ user.setUsername("user-" + id);
+ user.setSupabaseId(UUID.randomUUID());
+ if (team != null) {
+ user.setTeam(team);
+ }
+ if (role != null) {
+ // Authority ctor self-registers into user.getAuthorities().
+ new Authority(role, user);
+ }
+ return user;
+ }
+
+ private static HandlerMethod handlerMethodForAuto() {
+ return handlerMethod("handleAuto");
+ }
+
+ private static HandlerMethod handlerMethodForHeavy() {
+ return handlerMethod("handleHeavy");
+ }
+
+ private static HandlerMethod handlerMethodForPlain() {
+ return handlerMethod("handlePlain");
+ }
+
+ private static HandlerMethod handlerMethod(String name) {
+ try {
+ Method m = FakeController.class.getDeclaredMethod(name);
+ return new HandlerMethod(new FakeController(), m);
+ } catch (NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ static class FakeController {
+ // No explicit resourceWeight → default sentinel Integer.MIN_VALUE → clamps to 1.
+ @AutoJobPostMapping(value = "/auto")
+ public void handleAuto() {}
+
+ // Above-max weight to exercise the clamp-to-100 branch.
+ @AutoJobPostMapping(value = "/heavy", resourceWeight = 5000)
+ public void handleHeavy() {}
+
+ public void handlePlain() {}
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/AnonymousUserCleanupServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/AnonymousUserCleanupServiceTest.java
new file mode 100644
index 000000000..40ddcc7ac
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/AnonymousUserCleanupServiceTest.java
@@ -0,0 +1,351 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.saas.repository.SupabaseUserRepository;
+
+/**
+ * Unit tests for {@link AnonymousUserCleanupService}.
+ *
+ *
The service is a {@code @Scheduled} cleanup job. Its three {@code @Value} fields ({@code
+ * anonEnabled}, {@code retentionDays}, {@code batchSize}) are field-injected, so each scenario
+ * primes them with {@link ReflectionTestUtils}. The {@code cleanup()} method is invoked directly
+ * (no Spring scheduler). Both repositories return id {@link Stream}s that the service partitions
+ * into fixed-size batches via {@code Collectors.groupingBy}, then deletes each batch.
+ *
+ *
Important: the streams are consumed inside try-with-resources, so the SupabaseUser stream is a
+ * {@code Stream} and the User stream is a {@code Stream}; each stub must return a fresh
+ * stream (a {@code Stream} is single-use).
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AnonymousUserCleanupServiceTest {
+
+ @Mock private UserRepository userRepository;
+ @Mock private SupabaseUserRepository supabaseUserRepository;
+
+ private AnonymousUserCleanupService service;
+
+ private AnonymousUserCleanupService newService(
+ boolean anonEnabled, int retentionDays, int batchSize) {
+ AnonymousUserCleanupService s =
+ new AnonymousUserCleanupService(userRepository, supabaseUserRepository);
+ ReflectionTestUtils.setField(s, "anonEnabled", anonEnabled);
+ ReflectionTestUtils.setField(s, "retentionDays", retentionDays);
+ ReflectionTestUtils.setField(s, "batchSize", batchSize);
+ return s;
+ }
+
+ private static List uuids(int n) {
+ List out = new ArrayList<>(n);
+ for (int i = 0; i < n; i++) {
+ out.add(UUID.randomUUID());
+ }
+ return out;
+ }
+
+ private static List longs(int n) {
+ List out = new ArrayList<>(n);
+ for (long i = 0; i < n; i++) {
+ out.add(i);
+ }
+ return out;
+ }
+
+ @Nested
+ @DisplayName("guard clauses - no work performed")
+ class Guards {
+
+ @Test
+ @DisplayName("anonymous auth disabled: returns early, touches neither repository")
+ void anonDisabled_noop() {
+ service = newService(false, 30, 100);
+
+ service.cleanup();
+
+ verifyNoInteractions(userRepository, supabaseUserRepository);
+ }
+
+ @Test
+ @DisplayName("retentionDays == 0: returns early, touches neither repository")
+ void zeroRetention_noop() {
+ service = newService(true, 0, 100);
+
+ service.cleanup();
+
+ verifyNoInteractions(userRepository, supabaseUserRepository);
+ }
+
+ @Test
+ @DisplayName("negative retentionDays: returns early, touches neither repository")
+ void negativeRetention_noop() {
+ service = newService(true, -5, 100);
+
+ service.cleanup();
+
+ verifyNoInteractions(userRepository, supabaseUserRepository);
+ }
+ }
+
+ @Nested
+ @DisplayName("cutoff date derivation")
+ class CutoffDate {
+
+ @Test
+ @DisplayName("queries both repositories with a cutoff ~retentionDays before now")
+ void cutoffIsRetentionDaysBeforeNow() {
+ service = newService(true, 30, 100);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+
+ LocalDateTime expectedLower = LocalDateTime.now().minusDays(30).minusMinutes(1);
+ LocalDateTime expectedUpper = LocalDateTime.now().minusDays(30).plusMinutes(1);
+
+ service.cleanup();
+
+ ArgumentCaptor supaCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(supabaseUserRepository)
+ .findByCreatedAtBeforeAndIsAnonymousTrue(supaCutoff.capture());
+ assertThat(supaCutoff.getValue()).isBetween(expectedLower, expectedUpper);
+
+ ArgumentCaptor userCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(userRepository).findByUsernameIsNullAndCreatedAtBefore(userCutoff.capture());
+ assertThat(userCutoff.getValue()).isBetween(expectedLower, expectedUpper);
+ }
+
+ @Test
+ @DisplayName("both repositories receive the same cutoff instant")
+ void sameCutoffForBothRepositories() {
+ service = newService(true, 7, 100);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+
+ service.cleanup();
+
+ ArgumentCaptor supaCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(supabaseUserRepository)
+ .findByCreatedAtBeforeAndIsAnonymousTrue(supaCutoff.capture());
+ ArgumentCaptor userCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(userRepository).findByUsernameIsNullAndCreatedAtBefore(userCutoff.capture());
+
+ assertThat(supaCutoff.getValue()).isEqualTo(userCutoff.getValue());
+ }
+ }
+
+ @Nested
+ @DisplayName("empty result sets")
+ class EmptyStreams {
+
+ @Test
+ @DisplayName("no stale users: queries run but no batch delete is issued")
+ void noStaleUsers_noDeletes() {
+ service = newService(true, 30, 100);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+
+ service.cleanup();
+
+ verify(supabaseUserRepository)
+ .findByCreatedAtBeforeAndIsAnonymousTrue(any(LocalDateTime.class));
+ verify(userRepository).findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class));
+ verify(supabaseUserRepository, never()).deleteAllByIdInBatch(any());
+ verify(userRepository, never()).deleteAllByIdInBatch(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("single-batch deletion")
+ class SingleBatch {
+
+ @Test
+ @DisplayName("count below batch size deletes everything in one batch per repository")
+ void belowBatchSize_singleDelete() {
+ service = newService(true, 30, 100);
+ List supaIds = uuids(3);
+ List userIds = longs(5);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(supaIds.stream());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(userIds.stream());
+
+ service.cleanup();
+
+ verify(supabaseUserRepository, times(1)).deleteAllByIdInBatch(supaIds);
+ verify(userRepository, times(1)).deleteAllByIdInBatch(userIds);
+ }
+
+ @Test
+ @DisplayName("count exactly equal to batch size still produces exactly one batch")
+ void exactlyBatchSize_singleDelete() {
+ service = newService(true, 30, 4);
+ List supaIds = uuids(4);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(supaIds.stream());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+
+ service.cleanup();
+
+ verify(supabaseUserRepository, times(1)).deleteAllByIdInBatch(supaIds);
+ }
+ }
+
+ @Nested
+ @DisplayName("multi-batch partitioning")
+ class MultiBatch {
+
+ @Test
+ @DisplayName("supabase ids split into fixed-size batches with a final remainder batch")
+ void supabaseIdsPartitioned() {
+ service = newService(true, 30, 2);
+ // 5 ids, batch 2 -> batches of [2,2,1]
+ List supaIds = uuids(5);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(supaIds.stream());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+
+ service.cleanup();
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(supabaseUserRepository, times(3)).deleteAllByIdInBatch(captor.capture());
+
+ List> batches = captor.getAllValues();
+ // Two full batches and one remainder batch (order of values() not asserted on).
+ assertThat(batches).extracting(List::size).containsExactlyInAnyOrder(2, 2, 1);
+ // Every id is deleted exactly once across all batches.
+ assertThat(batches.stream().flatMap(List::stream))
+ .containsExactlyInAnyOrderElementsOf(supaIds);
+ }
+
+ @Test
+ @DisplayName("user ids split into fixed-size batches; even multiple yields no remainder")
+ void userIdsPartitionedEvenly() {
+ service = newService(true, 30, 3);
+ // 6 ids, batch 3 -> batches of [3,3]
+ List userIds = longs(6);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(userIds.stream());
+
+ service.cleanup();
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(userRepository, times(2)).deleteAllByIdInBatch(captor.capture());
+
+ List> batches = captor.getAllValues();
+ assertThat(batches).extracting(List::size).containsExactlyInAnyOrder(3, 3);
+ // Within a batch, encounter order is preserved by groupingBy.
+ assertThat(batches).anySatisfy(b -> assertThat(b).containsExactly(0L, 1L, 2L));
+ assertThat(batches).anySatisfy(b -> assertThat(b).containsExactly(3L, 4L, 5L));
+ }
+
+ @Test
+ @DisplayName("batch size of 1 produces one delete call per id")
+ void batchSizeOne_oneDeletePerId() {
+ service = newService(true, 30, 1);
+ List userIds = longs(4);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(Stream.empty());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(userIds.stream());
+
+ service.cleanup();
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(userRepository, times(4)).deleteAllByIdInBatch(captor.capture());
+ assertThat(captor.getAllValues()).extracting(List::size).containsExactly(1, 1, 1, 1);
+ }
+ }
+
+ @Nested
+ @DisplayName("both repositories are cleaned in one run")
+ class BothRepositories {
+
+ @Test
+ @DisplayName("supabase users are processed before legacy users, each in their own batches")
+ void supabaseThenUsers() {
+ service = newService(true, 30, 2);
+ List supaIds = uuids(3); // [2,1]
+ List userIds = longs(3); // [2,1]
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(supaIds.stream());
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(userIds.stream());
+
+ service.cleanup();
+
+ verify(supabaseUserRepository, times(2)).deleteAllByIdInBatch(any());
+ verify(userRepository, times(2)).deleteAllByIdInBatch(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("stream lifecycle")
+ class StreamLifecycle {
+
+ @Test
+ @DisplayName("each streamed result is closed via try-with-resources")
+ void streamsAreClosed() {
+ service = newService(true, 30, 100);
+
+ boolean[] supaClosed = {false};
+ boolean[] userClosed = {false};
+ Stream supaStream = uuids(2).stream().onClose(() -> supaClosed[0] = true);
+ Stream userStream = longs(2).stream().onClose(() -> userClosed[0] = true);
+ when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
+ any(LocalDateTime.class)))
+ .thenReturn(supaStream);
+ when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
+ .thenReturn(userStream);
+
+ service.cleanup();
+
+ assertThat(supaClosed[0]).as("supabase id stream closed").isTrue();
+ assertThat(userClosed[0]).as("user id stream closed").isTrue();
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java b/app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java
new file mode 100644
index 000000000..f7d59b7b2
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java
@@ -0,0 +1,219 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import stirling.software.saas.config.CreditsProperties;
+
+/**
+ * Unit tests for {@link CreditResetScheduler}.
+ *
+ * The scheduler has two {@code @Scheduled} entry points that are invoked directly (no Spring
+ * context, no real cron firing). {@code resetCycleCredits()} parses the configured zone, builds a
+ * {@code LocalDateTime} in that zone, and delegates to {@link CreditService} for users then teams,
+ * swallowing any exception. {@code performDailyMaintenance()} is a pure no-op log step that must
+ * never touch {@link CreditService}.
+ */
+@ExtendWith(MockitoExtension.class)
+class CreditResetSchedulerTest {
+
+ @Mock private CreditService creditService;
+
+ /** Real CreditsProperties (a simple @Data POJO) configured per test via its nested Reset. */
+ private static CreditsProperties props(String cron, String zone) {
+ CreditsProperties p = new CreditsProperties();
+ p.getReset().setCron(cron);
+ p.getReset().setZone(zone);
+ return p;
+ }
+
+ private CreditResetScheduler scheduler(CreditsProperties props) {
+ return new CreditResetScheduler(creditService, props);
+ }
+
+ @Nested
+ @DisplayName("resetCycleCredits - happy path")
+ class ResetHappyPath {
+
+ @Test
+ @DisplayName("resets users then teams exactly once each")
+ void resetsUsersThenTeamsOnce() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+
+ s.resetCycleCredits();
+
+ verify(creditService, times(1)).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+ verify(creditService, times(1)).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
+ }
+
+ @Test
+ @DisplayName("users are reset strictly before teams")
+ void usersResetBeforeTeams() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+
+ s.resetCycleCredits();
+
+ InOrder order = inOrder(creditService);
+ order.verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+ order.verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
+ order.verifyNoMoreInteractions();
+ }
+
+ @Test
+ @DisplayName("passes a non-null reset time and uses the same instant for users and teams")
+ void passesSameNonNullResetTimeToBoth() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+
+ s.resetCycleCredits();
+
+ ArgumentCaptor userTime = ArgumentCaptor.forClass(LocalDateTime.class);
+ ArgumentCaptor teamTime = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(creditService).resetCycleCreditsForAllUsers(userTime.capture());
+ verify(creditService).resetCycleCreditsForAllTeams(teamTime.capture());
+
+ assertThat(userTime.getValue()).isNotNull();
+ assertThat(teamTime.getValue()).isNotNull();
+ // The very same LocalDateTime instance is threaded through both calls.
+ assertThat(teamTime.getValue()).isSameAs(userTime.getValue());
+ }
+
+ @Test
+ @DisplayName("reset time is computed in the configured zone (~now)")
+ void resetTimeMatchesConfiguredZone() {
+ String zone = "UTC";
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone));
+ LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone));
+
+ s.resetCycleCredits();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(creditService).resetCycleCreditsForAllUsers(captor.capture());
+ // Wall-clock now in the same zone; allow generous slack to avoid flakiness.
+ assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS));
+ }
+
+ @Test
+ @DisplayName("a non-UTC zone is honored when building the reset time")
+ void nonUtcZoneHonored() {
+ String zone = "America/New_York";
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone));
+ LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone));
+
+ s.resetCycleCredits();
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class);
+ verify(creditService).resetCycleCreditsForAllUsers(captor.capture());
+ assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS));
+ }
+ }
+
+ @Nested
+ @DisplayName("resetCycleCredits - error handling (exceptions are swallowed)")
+ class ResetErrorHandling {
+
+ @Test
+ @DisplayName("user-reset failure is swallowed and short-circuits the team reset")
+ void userResetThrows_swallowedAndTeamsSkipped() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+ doThrow(new RuntimeException("user reset boom"))
+ .when(creditService)
+ .resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+
+ // Must not propagate.
+ s.resetCycleCredits();
+
+ verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+ // Exception thrown before the team call is reached.
+ verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
+ }
+
+ @Test
+ @DisplayName("team-reset failure is swallowed after users already reset")
+ void teamResetThrows_swallowedAfterUsersReset() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+ doThrow(new RuntimeException("team reset boom"))
+ .when(creditService)
+ .resetCycleCreditsForAllTeams(any(LocalDateTime.class));
+
+ // Must not propagate.
+ s.resetCycleCredits();
+
+ verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+ verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
+ }
+
+ @Test
+ @DisplayName("an invalid zone string is swallowed and no reset work is attempted")
+ void invalidZone_swallowedNoResets() {
+ // ZoneId.of on a bad id throws DateTimeException before any delegation occurs.
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "Not/AZone"));
+
+ // Must not propagate.
+ s.resetCycleCredits();
+
+ verifyNoInteractions(creditService);
+ }
+
+ @Test
+ @DisplayName("any RuntimeException subtype from the user reset is swallowed")
+ void userResetRuntimeExceptionTolerated() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+ // Production catches (Exception e); any RuntimeException is absorbed.
+ doThrow(new IllegalStateException("transient"))
+ .when(creditService)
+ .resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+
+ s.resetCycleCredits();
+
+ verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
+ verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("performDailyMaintenance")
+ class DailyMaintenance {
+
+ @Test
+ @DisplayName("runs cleanly and never touches the credit service")
+ void noOp_doesNotTouchCreditService() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+
+ s.performDailyMaintenance();
+
+ verifyNoInteractions(creditService);
+ }
+
+ @Test
+ @DisplayName("is idempotent across repeated invocations")
+ void repeatedInvocationsRemainNoOp() {
+ CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
+
+ s.performDailyMaintenance();
+ s.performDailyMaintenance();
+ s.performDailyMaintenance();
+
+ verifyNoInteractions(creditService);
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java
new file mode 100644
index 000000000..eb7733f5f
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java
@@ -0,0 +1,1291 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
+import stirling.software.proprietary.security.model.Authority;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.proprietary.security.service.UserService;
+import stirling.software.saas.billing.service.StripeUsageReportingService;
+import stirling.software.saas.config.CreditsProperties;
+import stirling.software.saas.model.CreditConsumptionResult;
+import stirling.software.saas.model.TeamCredit;
+import stirling.software.saas.model.UserCredit;
+import stirling.software.saas.repository.TeamCreditRepository;
+import stirling.software.saas.repository.UserCreditRepository;
+import stirling.software.saas.service.CreditService.CreditSummary;
+
+/**
+ * Unit tests for {@link CreditService}.
+ *
+ * The service is built with mocked repositories/collaborators and a real {@link
+ * SimpleMeterRegistry} (Micrometer counters/gauges are constructed in the ctor and need a live
+ * registry). A real {@link CreditsProperties} carries the default role allocations and the default
+ * monthly-reset cron/zone so the private allocation + scheduled-reset arithmetic is exercised with
+ * production defaults. Credit math is pure arithmetic, so exact numbers are asserted.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CreditServiceTest {
+
+ @Mock private UserCreditRepository userCreditRepository;
+ @Mock private TeamCreditRepository teamCreditRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private UserService userService;
+ @Mock private TeamCreditService teamCreditService;
+ @Mock private StripeUsageReportingService stripeUsageReportingService;
+ @Mock private SaasUserExtensionService saasUserExtensionService;
+ @Mock private SaasTeamExtensionService saasTeamExtensionService;
+
+ private CreditsProperties creditsProperties;
+ private MeterRegistry meterRegistry;
+ private CreditService service;
+
+ private static final String VALID_SUPABASE = "11111111-1111-1111-1111-111111111111";
+
+ @BeforeEach
+ void setUp() {
+ // Defensive: another test in the JVM may have left a synchronization registered. Clearing
+ // guarantees the no-active-tx path runs the Stripe report synchronously in these tests.
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.clear();
+ }
+ creditsProperties = new CreditsProperties();
+ meterRegistry = new SimpleMeterRegistry();
+ service =
+ new CreditService(
+ userCreditRepository,
+ teamCreditRepository,
+ userRepository,
+ userService,
+ creditsProperties,
+ teamCreditService,
+ stripeUsageReportingService,
+ saasUserExtensionService,
+ saasTeamExtensionService,
+ meterRegistry);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Fixtures
+ // ----------------------------------------------------------------------------------------
+
+ private static User user(Long id, String username, String... roles) {
+ User u = new User();
+ u.setId(id);
+ u.setUsername(username);
+ for (String role : roles) {
+ new Authority(role, u); // constructor adds itself to u.getAuthorities()
+ }
+ return u;
+ }
+
+ /** A UserCredit whose last reset is "now", so getOrCreateUserCredits never triggers a reset. */
+ private static UserCredit userCredit(User u, int cycleRemaining, int boughtRemaining) {
+ UserCredit c = new UserCredit(u);
+ c.setCycleCreditsRemaining(cycleRemaining);
+ c.setCycleCreditsAllocated(cycleRemaining);
+ c.setBoughtCreditsRemaining(boughtRemaining);
+ c.setTotalBoughtCredits(boughtRemaining);
+ c.setLastCycleResetAt(LocalDateTime.now());
+ return c;
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Lookups by API key / Supabase ID / user ID
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Lookups")
+ class Lookups {
+
+ @Test
+ @DisplayName("getUserCreditsByApiKey delegates to repository")
+ void byApiKey_delegates() {
+ User u = user(1L, "alice");
+ UserCredit c = userCredit(u, 5, 0);
+ when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.of(c));
+
+ assertThat(service.getUserCreditsByApiKey("key")).containsSame(c);
+ }
+
+ @Test
+ @DisplayName("getUserCreditsBySupabaseId parses a valid UUID and queries the repo")
+ void bySupabaseId_validUuid() {
+ User u = user(1L, "alice");
+ UserCredit c = userCredit(u, 5, 0);
+ when(userCreditRepository.findBySupabaseId(UUID.fromString(VALID_SUPABASE)))
+ .thenReturn(Optional.of(c));
+
+ assertThat(service.getUserCreditsBySupabaseId(VALID_SUPABASE)).containsSame(c);
+ }
+
+ @Test
+ @DisplayName("getUserCreditsBySupabaseId returns empty on malformed UUID (no repo call)")
+ void bySupabaseId_invalidUuid() {
+ assertThat(service.getUserCreditsBySupabaseId("not-a-uuid")).isEmpty();
+ verify(userCreditRepository, never()).findBySupabaseId(any());
+ }
+
+ @Test
+ @DisplayName("getUserCreditsByUserId delegates to repository")
+ void byUserId_delegates() {
+ User u = user(7L, "bob");
+ UserCredit c = userCredit(u, 3, 1);
+ when(userCreditRepository.findByUserId(7L)).thenReturn(Optional.of(c));
+
+ assertThat(service.getUserCreditsByUserId(7L)).containsSame(c);
+ }
+
+ @Test
+ @DisplayName("getUserBySupabaseId delegates to UserService")
+ void userBySupabaseId_delegates() {
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ User u = user(1L, "alice");
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+
+ assertThat(service.getUserBySupabaseId(id)).containsSame(u);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // getOrCreateUserCredits — existing (reset / no reset) and creation
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("getOrCreateUserCredits")
+ class GetOrCreate {
+
+ @Test
+ @DisplayName("returns the existing row unchanged when no cycle reset is due")
+ void existing_noResetDue_returnsAsIs() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UserCredit existing = userCredit(u, 42, 7); // lastReset = now -> not due
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(existing));
+
+ UserCredit result = service.getOrCreateUserCredits(u);
+
+ assertThat(result).isSameAs(existing);
+ assertThat(result.getCycleCreditsRemaining()).isEqualTo(42);
+ verify(userCreditRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("resets cycle credits to the role allocation when reset is overdue")
+ void existing_resetDue_resetsToAllocation() {
+ User u = user(1L, "alice", "ROLE_USER"); // default ROLE_USER -> 50
+ UserCredit existing = userCredit(u, 3, 9);
+ // Force "reset due": last reset far in the past, before the most-recent scheduled
+ // reset.
+ existing.setLastCycleResetAt(LocalDateTime.now().minusYears(1));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(existing));
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit result = service.getOrCreateUserCredits(u);
+
+ // Cycle reset to ROLE_USER allocation; bought pool is untouched by resetCycleCredits.
+ assertThat(result.getCycleCreditsRemaining()).isEqualTo(50);
+ assertThat(result.getCycleCreditsAllocated()).isEqualTo(50);
+ assertThat(result.getBoughtCreditsRemaining()).isEqualTo(9);
+ verify(userCreditRepository).save(existing);
+ }
+
+ @Test
+ @DisplayName("creates a new credit row allocated for the user's role when none exists")
+ void missing_createsWithAllocation() {
+ User u = user(1L, "pro", "ROLE_PRO_USER"); // 500
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.empty());
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit result = service.getOrCreateUserCredits(u);
+
+ assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
+ assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
+ assertThat(result.getLastCycleResetAt()).isNotNull();
+ verify(userCreditRepository).save(any(UserCredit.class));
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Role-based cycle allocation (exercised via initializeCreditsForUser / getOrCreate)
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Cycle allocation by role")
+ class Allocation {
+
+ @Test
+ @DisplayName("admin with adminUnlimited gets Integer.MAX_VALUE")
+ void admin_unlimited() {
+ User u = user(1L, "admin", "ROLE_ADMIN");
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit c = service.initializeCreditsForUser(u);
+
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(Integer.MAX_VALUE);
+ }
+
+ @Test
+ @DisplayName("admin is bounded by allocation map when adminUnlimited is disabled")
+ void admin_bounded_whenUnlimitedDisabled() {
+ creditsProperties.getCycle().setAdminUnlimited(false);
+ User u = user(1L, "admin", "ROLE_ADMIN"); // map has ROLE_ADMIN -> 1000
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit c = service.initializeCreditsForUser(u);
+
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(1000);
+ }
+
+ @Test
+ @DisplayName("internal API user always gets unlimited credits")
+ void internalApi_unlimited() {
+ User u = user(1L, "internal", "ROLE_INTERNAL_API_USER");
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit c = service.initializeCreditsForUser(u);
+
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(Integer.MAX_VALUE);
+ }
+
+ @Test
+ @DisplayName("pro user gets the configured ROLE_PRO_USER allocation")
+ void pro_getsConfiguredAmount() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit c = service.initializeCreditsForUser(u);
+
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(500);
+ }
+
+ @Test
+ @DisplayName("unknown role falls back to ROLE_USER default allocation")
+ void unknownRole_defaultsToUser() {
+ User u = user(1L, "mystery", "ROLE_SOMETHING_ELSE");
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit c = service.initializeCreditsForUser(u);
+
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(50);
+ }
+
+ @Test
+ @DisplayName("user with no roles gets zero allocation (rolesString empty, default path)")
+ void noRoles_defaultAllocation() {
+ // getRolesAsString() returns "" (not null) for a user with no authorities, so the
+ // null-guard is skipped and the ROLE_USER default applies.
+ User u = user(1L, "empty");
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ UserCredit c = service.initializeCreditsForUser(u);
+
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(50);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // hasCreditsAvailable (by API key) — present / lazy-create / no user
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("hasCreditsAvailable by API key")
+ class HasCreditsApiKey {
+
+ @Test
+ @DisplayName("true when an existing row has a positive balance")
+ void existingRow_positive_true() {
+ User u = user(1L, "alice");
+ when(userCreditRepository.findByUserApiKey("key"))
+ .thenReturn(Optional.of(userCredit(u, 5, 0)));
+
+ assertThat(service.hasCreditsAvailable("key")).isTrue();
+ }
+
+ @Test
+ @DisplayName("false when an existing row is fully depleted")
+ void existingRow_zero_false() {
+ User u = user(1L, "alice");
+ when(userCreditRepository.findByUserApiKey("key"))
+ .thenReturn(Optional.of(userCredit(u, 0, 0)));
+
+ assertThat(service.hasCreditsAvailable("key")).isFalse();
+ }
+
+ @Test
+ @DisplayName("lazy-creates credits for a user that has no row yet")
+ void noRow_userExists_lazyCreates() {
+ User u = user(1L, "alice", "ROLE_PRO_USER");
+ when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.empty());
+ when(userRepository.findByApiKey("key")).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.empty());
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ assertThat(service.hasCreditsAvailable("key")).isTrue(); // 500 allocated
+ verify(userCreditRepository).save(any(UserCredit.class));
+ }
+
+ @Test
+ @DisplayName("false when no user is found for the API key")
+ void noUser_false() {
+ when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.empty());
+ when(userRepository.findByApiKey("key")).thenReturn(Optional.empty());
+
+ assertThat(service.hasCreditsAvailable("key")).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // consumeCredit (by API key) — atomic update success/failure
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("consumeCredit by API key")
+ class ConsumeApiKey {
+
+ @Test
+ @DisplayName("returns true when the atomic update affects exactly one row")
+ void oneRowUpdated_true() {
+ when(userCreditRepository.consumeCredit("key", 3)).thenReturn(1);
+
+ assertThat(service.consumeCredit("key", 3)).isTrue();
+ }
+
+ @Test
+ @DisplayName("returns false when no row is updated (insufficient credits)")
+ void zeroRowsUpdated_false() {
+ when(userCreditRepository.consumeCredit("key", 3)).thenReturn(0);
+
+ assertThat(service.consumeCredit("key", 3)).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // consumeCreditBySupabaseId — prepaid, metered (full free / overage), errors
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("consumeCreditBySupabaseId")
+ class ConsumeBySupabase {
+
+ @Test
+ @DisplayName("invalid UUID returns false without touching the user service")
+ void invalidUuid_false() {
+ assertThat(service.consumeCreditBySupabaseId("nope", 1)).isFalse();
+ verify(userService, never()).findBySupabaseId(any());
+ }
+
+ @Test
+ @DisplayName("missing user returns false")
+ void missingUser_false() {
+ when(userService.findBySupabaseId(any())).thenReturn(Optional.empty());
+
+ assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 1)).isFalse();
+ }
+
+ @Test
+ @DisplayName("prepaid (non-metered) user: one-row update returns true")
+ void prepaid_success() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
+ when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(1);
+
+ assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isTrue();
+ }
+
+ @Test
+ @DisplayName("prepaid user: zero-row update returns false (insufficient credits)")
+ void prepaid_insufficient_false() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
+ when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(0);
+
+ assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isFalse();
+ }
+
+ @Test
+ @DisplayName("metered user fully covered by free tier consumes from free tier only")
+ void metered_fullyFree_noStripe() {
+ User u = user(1L, "meter", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 10, 0)));
+ when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(1);
+
+ assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isTrue();
+
+ // Wholly covered by the free tier -> no Stripe report at all.
+ verify(stripeUsageReportingService, never())
+ .reportUsageToStripe(anyString(), anyInt(), anyString());
+ }
+
+ @Test
+ @DisplayName(
+ "metered user: overage consumes free credits then reports the overage to Stripe")
+ void metered_overage_reportsOverage() {
+ User u = user(1L, "meter", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+ // 3 free credits remain; need 10 -> 3 free + 7 overage.
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 3, 0)));
+ when(userCreditRepository.consumeCreditBySupabaseId(id, 3)).thenReturn(1);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ eq(VALID_SUPABASE), eq(7), any()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 7, "idem-key"))
+ .thenReturn(true);
+
+ boolean result = service.consumeCreditBySupabaseId(VALID_SUPABASE, 10);
+
+ assertThat(result).isTrue();
+ // Free portion debited in-tx; overage (7) metered to Stripe (sync, no active tx).
+ verify(userCreditRepository).consumeCreditBySupabaseId(id, 3);
+ verify(stripeUsageReportingService).reportUsageToStripe(VALID_SUPABASE, 7, "idem-key");
+ }
+
+ @Test
+ @DisplayName("metered user: full overage (no free credits) reports the entire amount")
+ void metered_fullOverage_reportsAll() {
+ User u = user(1L, "meter", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 0, 0)));
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ eq(VALID_SUPABASE), eq(5), any()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 5, "idem-key"))
+ .thenReturn(true);
+
+ boolean result = service.consumeCreditBySupabaseId(VALID_SUPABASE, 5);
+
+ assertThat(result).isTrue();
+ // No free credits to debit, so no DB consume; whole 5 metered.
+ verify(userCreditRepository, never()).consumeCreditBySupabaseId(any(), anyInt());
+ verify(stripeUsageReportingService).reportUsageToStripe(VALID_SUPABASE, 5, "idem-key");
+ }
+
+ @Test
+ @DisplayName("metered overage still returns true even when the Stripe report fails")
+ void metered_overage_stripeFails_stillTrue() {
+ // The DB debit already committed; a Stripe failure is logged + counted but the call
+ // succeeds so the user is not double-charged for an unbilled-but-owed overage.
+ User u = user(1L, "meter", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 0, 0)));
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ eq(VALID_SUPABASE), eq(5), any()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 5, "idem-key"))
+ .thenReturn(false);
+
+ assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 5)).isTrue();
+ }
+
+ @Test
+ @DisplayName("metered free-tier debit lost a concurrent race -> returns false")
+ void metered_freeTierRaceLost_false() {
+ // In-memory check said free tier covers it, but the atomic UPDATE found 0 rows.
+ User u = user(1L, "meter", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 10, 0)));
+ when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(0);
+
+ assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // hasCreditsAvailableBySupabaseId
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("hasCreditsAvailableBySupabaseId")
+ class HasCreditsSupabase {
+
+ @Test
+ @DisplayName("true for an existing row with a positive balance")
+ void existing_positive_true() {
+ User u = user(1L, "alice");
+ when(userCreditRepository.findBySupabaseId(UUID.fromString(VALID_SUPABASE)))
+ .thenReturn(Optional.of(userCredit(u, 2, 0)));
+
+ assertThat(service.hasCreditsAvailableBySupabaseId(VALID_SUPABASE)).isTrue();
+ }
+
+ @Test
+ @DisplayName("lazy-creates a row for a user without one")
+ void noRow_lazyCreates() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userCreditRepository.findBySupabaseId(id)).thenReturn(Optional.empty());
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.empty());
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ assertThat(service.hasCreditsAvailableBySupabaseId(VALID_SUPABASE)).isTrue();
+ }
+
+ @Test
+ @DisplayName("false when the UUID is malformed")
+ void invalidUuid_false() {
+ assertThat(service.hasCreditsAvailableBySupabaseId("bad")).isFalse();
+ }
+
+ @Test
+ @DisplayName("false when no user exists for a well-formed UUID")
+ void noUser_false() {
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userCreditRepository.findBySupabaseId(id)).thenReturn(Optional.empty());
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.empty());
+
+ assertThat(service.hasCreditsAvailableBySupabaseId(VALID_SUPABASE)).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Authentication-based helpers
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Authentication helpers")
+ class AuthHelpers {
+
+ @Test
+ @DisplayName("getUserCreditsFromAuthentication returns empty when no auth is present")
+ void noAuth_empty() {
+ SecurityContextHolder.clearContext();
+ assertThat(service.getUserCreditsFromAuthentication()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("API-key auth resolves credits by the principal user's id")
+ void apiKeyAuth_resolvesById() {
+ User u = user(9L, "alice");
+ UserCredit c = userCredit(u, 3, 0);
+ ApiKeyAuthenticationToken auth =
+ new ApiKeyAuthenticationToken(
+ u, "sk-test", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ when(userCreditRepository.findByUserId(9L)).thenReturn(Optional.of(c));
+
+ assertThat(service.getUserCreditsFromAuthentication()).containsSame(c);
+ }
+
+ @Test
+ @DisplayName("JWT/session (UsernamePassword) auth resolves credits by the principal id")
+ void jwtAuth_resolvesById() {
+ User u = user(9L, "alice");
+ UserCredit c = userCredit(u, 3, 0);
+ // 3-arg ctor -> authenticated principal.
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken(
+ u, "pw", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ when(userCreditRepository.findByUserId(9L)).thenReturn(Optional.of(c));
+
+ assertThat(service.getUserCreditsFromAuthentication()).containsSame(c);
+ }
+
+ @Test
+ @DisplayName("isApiKeyAuthenticated true only for an ApiKeyAuthenticationToken")
+ void isApiKeyAuthenticated() {
+ User u = user(9L, "alice");
+ SecurityContextHolder.getContext()
+ .setAuthentication(
+ new ApiKeyAuthenticationToken(
+ u, "sk", List.of(new SimpleGrantedAuthority("ROLE_USER"))));
+ assertThat(service.isApiKeyAuthenticated()).isTrue();
+ assertThat(service.isJwtAuthenticated()).isFalse();
+ }
+
+ @Test
+ @DisplayName("isJwtAuthenticated true only for a UsernamePasswordAuthenticationToken")
+ void isJwtAuthenticated() {
+ User u = user(9L, "alice");
+ SecurityContextHolder.getContext()
+ .setAuthentication(
+ new UsernamePasswordAuthenticationToken(
+ u, "pw", List.of(new SimpleGrantedAuthority("ROLE_USER"))));
+ assertThat(service.isJwtAuthenticated()).isTrue();
+ assertThat(service.isApiKeyAuthenticated()).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Admin credit mutations by username
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Credit mutations by username")
+ class MutationsByUsername {
+
+ @Test
+ @DisplayName("addBoughtCredits adds to the bought pool and persists")
+ void addBought_addsAndSaves() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UserCredit c = userCredit(u, 10, 5);
+ when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c));
+
+ service.addBoughtCredits("alice", 20);
+
+ assertThat(c.getBoughtCreditsRemaining()).isEqualTo(25);
+ assertThat(c.getTotalBoughtCredits()).isEqualTo(25);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("addBoughtCredits throws when the user does not exist")
+ void addBought_missingUser_throws() {
+ when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.addBoughtCredits("ghost", 5))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("User not found");
+ }
+
+ @Test
+ @DisplayName("setBoughtCredits overwrites both remaining and total bought")
+ void setBought_overwrites() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UserCredit c = userCredit(u, 10, 99);
+ when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c));
+
+ service.setBoughtCredits("alice", 7);
+
+ assertThat(c.getBoughtCreditsRemaining()).isEqualTo(7);
+ assertThat(c.getTotalBoughtCredits()).isEqualTo(7);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("setCycleCredits overwrites only the cycle remaining pool")
+ void setCycle_overwritesCycleOnly() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UserCredit c = userCredit(u, 10, 8);
+ when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c));
+
+ service.setCycleCredits("alice", 3);
+
+ assertThat(c.getCycleCreditsRemaining()).isEqualTo(3);
+ assertThat(c.getBoughtCreditsRemaining()).isEqualTo(8);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("setCycleCredits throws when the user does not exist")
+ void setCycle_missingUser_throws() {
+ when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.setCycleCredits("ghost", 5))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Admin credit mutations by Supabase ID
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Credit mutations by Supabase ID")
+ class MutationsBySupabase {
+
+ @Test
+ @DisplayName("addBoughtCreditsBySupabaseId adds to the bought pool")
+ void addBought_adds() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ UserCredit c = userCredit(u, 10, 5);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c));
+
+ service.addBoughtCreditsBySupabaseId(VALID_SUPABASE, 15);
+
+ assertThat(c.getBoughtCreditsRemaining()).isEqualTo(20);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("setCycleCreditsBySupabaseId overwrites the cycle remaining pool")
+ void setCycle_overwrites() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ UserCredit c = userCredit(u, 10, 5);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c));
+
+ service.setCycleCreditsBySupabaseId(VALID_SUPABASE, 2);
+
+ assertThat(c.getCycleCreditsRemaining()).isEqualTo(2);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("mutation by Supabase ID throws when the user is missing")
+ void missingUser_throws() {
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.setBoughtCreditsBySupabaseId(VALID_SUPABASE, 5))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("User not found");
+ }
+
+ @Test
+ @DisplayName("mutation by Supabase ID throws on a malformed UUID")
+ void invalidUuid_throws() {
+ assertThatThrownBy(() -> service.setBoughtCreditsBySupabaseId("bad-uuid", 5))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Cycle resets for all users / teams
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Bulk cycle resets")
+ class BulkResets {
+
+ @Test
+ @DisplayName("resetCycleCreditsForAllUsers resets each candidate to its role allocation")
+ void resetUsers_perRoleAllocation() {
+ LocalDateTime cutoff = LocalDateTime.now();
+ User proUser = user(1L, "pro", "ROLE_PRO_USER"); // 500
+ User freeUser = user(2L, "free", "ROLE_USER"); // 50
+ UserCredit proCredit = userCredit(proUser, 1, 0);
+ UserCredit freeCredit = userCredit(freeUser, 1, 0);
+ when(userCreditRepository.findCreditsNeedingCycleReset(cutoff))
+ .thenReturn(List.of(proCredit, freeCredit));
+
+ service.resetCycleCreditsForAllUsers(cutoff);
+
+ assertThat(proCredit.getCycleCreditsRemaining()).isEqualTo(500);
+ assertThat(proCredit.getLastCycleResetAt()).isEqualTo(cutoff);
+ assertThat(freeCredit.getCycleCreditsRemaining()).isEqualTo(50);
+ verify(userCreditRepository).save(proCredit);
+ verify(userCreditRepository).save(freeCredit);
+ }
+
+ @Test
+ @DisplayName(
+ "resetCycleCreditsForAllUsers no-arg overload uses 'now' and saves nothing when empty")
+ void resetUsers_noArg_emptyList() {
+ when(userCreditRepository.findCreditsNeedingCycleReset(any(LocalDateTime.class)))
+ .thenReturn(List.of());
+
+ service.resetCycleCreditsForAllUsers();
+
+ verify(userCreditRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("resetCycleCreditsForAllTeams resets each team to the fixed PRO allocation")
+ void resetTeams_fixedProAllocation() {
+ LocalDateTime cutoff = LocalDateTime.now();
+ Team team = new Team();
+ team.setId(100L);
+ TeamCredit tc = new TeamCredit(team);
+ tc.setCycleCreditsRemaining(1);
+ when(teamCreditRepository.findCreditsNeedingCycleReset(cutoff)).thenReturn(List.of(tc));
+
+ service.resetCycleCreditsForAllTeams(cutoff);
+
+ // Fixed PRO amount from the allocation map (ROLE_PRO_USER -> 500).
+ assertThat(tc.getCycleCreditsRemaining()).isEqualTo(500);
+ assertThat(tc.getCycleCreditsAllocated()).isEqualTo(500);
+ assertThat(tc.getLastCycleResetAt()).isEqualTo(cutoff);
+ verify(teamCreditRepository).save(tc);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Credit summaries
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Credit summaries")
+ class Summaries {
+
+ @Test
+ @DisplayName("getCreditSummaryByApiKey returns an empty summary when no row exists")
+ void byApiKey_missing_empty() {
+ when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.empty());
+
+ CreditSummary summary = service.getCreditSummaryByApiKey("key");
+
+ assertThat(summary.totalAvailableCredits).isZero();
+ assertThat(summary.unlimited).isFalse();
+ }
+
+ @Test
+ @DisplayName("getCreditSummaryByApiKey maps the row fields and flags unlimited correctly")
+ void byApiKey_mapsFields() {
+ User u = user(1L, "alice");
+ UserCredit c = userCredit(u, 30, 12);
+ c.setCycleCreditsAllocated(50);
+ when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.of(c));
+
+ CreditSummary summary = service.getCreditSummaryByApiKey("key");
+
+ assertThat(summary.cycleCreditsRemaining).isEqualTo(30);
+ assertThat(summary.cycleCreditsAllocated).isEqualTo(50);
+ assertThat(summary.boughtCreditsRemaining).isEqualTo(12);
+ assertThat(summary.totalAvailableCredits).isEqualTo(42);
+ assertThat(summary.unlimited).isFalse();
+ }
+
+ @Test
+ @DisplayName("getCreditSummaryByApiKey flags unlimited when cycle allocation is MAX_VALUE")
+ void byApiKey_unlimited() {
+ User u = user(1L, "admin");
+ UserCredit c = userCredit(u, 5, 0);
+ c.setCycleCreditsAllocated(Integer.MAX_VALUE);
+ when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.of(c));
+
+ assertThat(service.getCreditSummaryByApiKey("key").unlimited).isTrue();
+ }
+
+ @Test
+ @DisplayName("getCreditSummary(username) returns an empty summary for an unknown username")
+ void byUsername_missing_empty() {
+ when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
+
+ assertThat(service.getCreditSummary("ghost").totalAvailableCredits).isZero();
+ }
+
+ @Test
+ @DisplayName("getCreditSummary(username) maps the (lazily resolved) credit row")
+ void byUsername_maps() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UserCredit c = userCredit(u, 20, 5);
+ when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u));
+ when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c));
+
+ CreditSummary summary = service.getCreditSummary("alice");
+
+ assertThat(summary.cycleCreditsRemaining).isEqualTo(20);
+ assertThat(summary.totalAvailableCredits).isEqualTo(25);
+ }
+
+ @Test
+ @DisplayName("getCreditSummaryBySupabaseId returns empty for a malformed UUID")
+ void bySupabase_invalidUuid_empty() {
+ assertThat(service.getCreditSummaryBySupabaseId("bad").totalAvailableCredits).isZero();
+ }
+
+ @Test
+ @DisplayName("getCreditSummaryBySupabaseId returns empty when no user is found")
+ void bySupabase_noUser_empty() {
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.empty());
+
+ assertThat(service.getCreditSummaryBySupabaseId(VALID_SUPABASE).totalAvailableCredits)
+ .isZero();
+ }
+
+ @Test
+ @DisplayName("non-personal team member gets the shared team pool, never personal credits")
+ void bySupabase_teamMember_returnsTeamPool() {
+ User u = user(1L, "teamie", "ROLE_USER");
+ Team team = new Team();
+ team.setId(100L);
+ u.setTeam(team);
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+
+ TeamCredit tc = new TeamCredit(team);
+ tc.setCycleCreditsRemaining(300);
+ tc.setCycleCreditsAllocated(500);
+ tc.setBoughtCreditsRemaining(50);
+ tc.setTotalBoughtCredits(50);
+ when(teamCreditService.getTeamCredits(100L)).thenReturn(Optional.of(tc));
+
+ CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE);
+
+ assertThat(summary.cycleCreditsRemaining).isEqualTo(300);
+ assertThat(summary.totalAvailableCredits).isEqualTo(350);
+ assertThat(summary.unlimited).isFalse();
+ // Team path: personal credits must not be consulted.
+ verify(userCreditRepository, never()).findBySupabaseId(any());
+ }
+
+ @Test
+ @DisplayName("non-personal team with no credit record returns an empty summary")
+ void bySupabase_teamMember_noTeamCredit_empty() {
+ User u = user(1L, "teamie", "ROLE_USER");
+ Team team = new Team();
+ team.setId(100L);
+ u.setTeam(team);
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
+ when(teamCreditService.getTeamCredits(100L)).thenReturn(Optional.empty());
+
+ assertThat(service.getCreditSummaryBySupabaseId(VALID_SUPABASE).totalAvailableCredits)
+ .isZero();
+ }
+
+ @Test
+ @DisplayName("limited API team member uses personal credits, not the team pool")
+ void bySupabase_limitedApiUser_usesPersonal() {
+ User u = user(1L, "limited", "ROLE_LIMITED_API_USER");
+ Team team = new Team();
+ team.setId(100L);
+ u.setTeam(team);
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(userCreditRepository.findBySupabaseId(id))
+ .thenReturn(Optional.of(userCredit(u, 8, 0)));
+
+ CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE);
+
+ assertThat(summary.cycleCreditsRemaining).isEqualTo(8);
+ // Limited API users never consult the team pool.
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName("personal-team user returns personal credits")
+ void bySupabase_personalTeam_returnsPersonal() {
+ User u = user(1L, "solo", "ROLE_USER");
+ Team team = new Team();
+ team.setId(100L);
+ u.setTeam(team);
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(saasTeamExtensionService.isPersonal(team)).thenReturn(true);
+ when(userCreditRepository.findBySupabaseId(id))
+ .thenReturn(Optional.of(userCredit(u, 15, 5)));
+
+ CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE);
+
+ assertThat(summary.totalAvailableCredits).isEqualTo(20);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName("teamless personal user with no row triggers lazy initialization")
+ void bySupabase_noRow_lazyInit() {
+ User u = user(1L, "fresh", "ROLE_PRO_USER"); // 500
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u));
+ when(userCreditRepository.findBySupabaseId(id)).thenReturn(Optional.empty());
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE);
+
+ assertThat(summary.cycleCreditsAllocated).isEqualTo(500);
+ verify(userCreditRepository).save(any(UserCredit.class));
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Role-change refresh / allocation reset
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("Role-change credit refresh")
+ class RoleChange {
+
+ @Test
+ @DisplayName(
+ "refreshCreditsAfterRoleChange fully resets cycle credits to the new allocation")
+ void refresh_resetsToNewAllocation() {
+ User u = user(1L, "upgraded", "ROLE_PRO_USER"); // new allocation 500
+ UserCredit c = userCredit(u, 3, 40); // had only 3 cycle, 40 bought
+ when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.of(c));
+
+ service.refreshCreditsAfterRoleChange(u);
+
+ assertThat(c.getCycleCreditsRemaining()).isEqualTo(500);
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(500);
+ // Bought pool is preserved across a role change.
+ assertThat(c.getBoughtCreditsRemaining()).isEqualTo(40);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("refreshCreditsAfterRoleChange initializes credits when none exist")
+ void refresh_noRow_initializes() {
+ User u = user(1L, "new", "ROLE_USER");
+ when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.empty());
+ when(userCreditRepository.save(any(UserCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ service.refreshCreditsAfterRoleChange(u);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UserCredit.class);
+ verify(userCreditRepository).save(captor.capture());
+ assertThat(captor.getValue().getCycleCreditsAllocated()).isEqualTo(50);
+ }
+
+ @Test
+ @DisplayName("resetCycleAllocationForRoleChange performs a full reset to the given amount")
+ void resetAllocation_fullReset() {
+ User u = user(1L, "alice", "ROLE_USER");
+ UserCredit c = userCredit(u, 10, 25);
+ when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.of(c));
+
+ service.resetCycleAllocationForRoleChange(1L, 200);
+
+ assertThat(c.getCycleCreditsRemaining()).isEqualTo(200);
+ assertThat(c.getCycleCreditsAllocated()).isEqualTo(200);
+ assertThat(c.getBoughtCreditsRemaining()).isEqualTo(25);
+ verify(userCreditRepository).save(c);
+ }
+
+ @Test
+ @DisplayName("resetCycleAllocationForRoleChange throws when the user has no credit row")
+ void resetAllocation_missingRow_throws() {
+ when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.resetCycleAllocationForRoleChange(1L, 200))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("User credits not found");
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // consumeCreditWithWaterfall — the explicit Pro-billing waterfall
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("consumeCreditWithWaterfall")
+ class Waterfall {
+
+ @Test
+ @DisplayName("internal backend API user is unlimited; no Supabase ID needed")
+ void internalApiUser_unlimited() {
+ User u = user(1L, "backend", "STIRLING-PDF-BACKEND-API-USER");
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 100, true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("INTERNAL_API_UNLIMITED");
+ verify(userCreditRepository, never()).hasCycleCredits(any(), anyInt());
+ }
+
+ @Test
+ @DisplayName("user without a Supabase ID fails")
+ void noSupabaseId_failure() {
+ User u = user(1L, "noid", "ROLE_PRO_USER");
+ u.setSupabaseId(null);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("no Supabase ID");
+ }
+
+ @Test
+ @DisplayName("step 1: consumes from cycle credits when available")
+ void cycleCredits_consumed() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ u.setSupabaseId(id);
+ when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.TRUE);
+ when(userCreditRepository.consumeCycleCredits(id, 5)).thenReturn(1);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("CYCLE_CREDITS");
+ verify(userCreditRepository, never()).hasBoughtCredits(any(), anyInt());
+ }
+
+ @Test
+ @DisplayName("step 2: falls through to bought credits when cycle credits are insufficient")
+ void boughtCredits_consumed() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ u.setSupabaseId(id);
+ when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.TRUE);
+ when(userCreditRepository.consumeBoughtCredits(id, 5)).thenReturn(1);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("BOUGHT_CREDITS");
+ }
+
+ @Test
+ @DisplayName("cycle check passes but the atomic update loses a race -> falls to bought")
+ void cycleRaceLost_fallsToBought() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ u.setSupabaseId(id);
+ when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.TRUE);
+ when(userCreditRepository.consumeCycleCredits(id, 5)).thenReturn(0); // lost the race
+ when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.TRUE);
+ when(userCreditRepository.consumeBoughtCredits(id, 5)).thenReturn(1);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("BOUGHT_CREDITS");
+ }
+
+ @Test
+ @DisplayName("step 3: metered billing schedules a Stripe report and succeeds")
+ void metered_subscription_success() {
+ User u = user(1L, "meter", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ u.setSupabaseId(id);
+ when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ eq(VALID_SUPABASE), eq(5), any()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 5, "idem-key"))
+ .thenReturn(true);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("METERED_SUBSCRIPTION");
+ // No active tx -> report runs synchronously with the full amount.
+ verify(stripeUsageReportingService).reportUsageToStripe(VALID_SUPABASE, 5, "idem-key");
+ }
+
+ @Test
+ @DisplayName(
+ "Pro user without metered billing is rejected with the overage-billing message")
+ void proWithoutMetered_rejected() {
+ User u = user(1L, "pro", "ROLE_PRO_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ u.setSupabaseId(id);
+ when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("overage billing");
+ }
+
+ @Test
+ @DisplayName("non-Pro user with no credit source is rejected as INSUFFICIENT_CREDITS")
+ void nonPro_noSource_insufficient() {
+ User u = user(1L, "free", "ROLE_USER");
+ UUID id = UUID.fromString(VALID_SUPABASE);
+ u.setSupabaseId(id);
+ when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.FALSE);
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo("INSUFFICIENT_CREDITS");
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // CreditSummary value holder
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("CreditSummary value holder")
+ class SummaryHolder {
+
+ @Test
+ @DisplayName("no-arg constructor zeroes everything and is not unlimited")
+ void emptySummary() {
+ CreditSummary s = new CreditSummary();
+
+ assertThat(s.cycleCreditsRemaining).isZero();
+ assertThat(s.cycleCreditsAllocated).isZero();
+ assertThat(s.boughtCreditsRemaining).isZero();
+ assertThat(s.totalBoughtCredits).isZero();
+ assertThat(s.totalAvailableCredits).isZero();
+ assertThat(s.cycleResetDate).isNull();
+ assertThat(s.lastApiUsage).isNull();
+ assertThat(s.unlimited).isFalse();
+ }
+
+ @Test
+ @DisplayName("all-args constructor wires public fields verbatim")
+ void fullSummary() {
+ LocalDateTime reset = LocalDateTime.now();
+ LocalDateTime usage = reset.plusMinutes(1);
+ CreditSummary s = new CreditSummary(30, 50, 12, 100, 42, reset, usage, true);
+
+ assertThat(s.cycleCreditsRemaining).isEqualTo(30);
+ assertThat(s.cycleCreditsAllocated).isEqualTo(50);
+ assertThat(s.boughtCreditsRemaining).isEqualTo(12);
+ assertThat(s.totalBoughtCredits).isEqualTo(100);
+ assertThat(s.totalAvailableCredits).isEqualTo(42);
+ assertThat(s.cycleResetDate).isEqualTo(reset);
+ assertThat(s.lastApiUsage).isEqualTo(usage);
+ assertThat(s.unlimited).isTrue();
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java
new file mode 100644
index 000000000..26aca1c57
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java
@@ -0,0 +1,586 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.sql.SQLException;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.config.CreditsProperties;
+import stirling.software.saas.model.UserErrorTracker;
+import stirling.software.saas.repository.UserErrorTrackerRepository;
+import stirling.software.saas.service.ErrorTrackingService.ErrorInfo;
+
+/**
+ * Unit tests for {@link ErrorTrackingService}.
+ *
+ * The service has two distinct tracking paths chosen at construction time based on {@link
+ * CreditsProperties.Cache#isLocalEnabled()}: an in-memory Caffeine cache path and a direct-to-DB
+ * fallback. Because the cache field is final and decided in the constructor, each path is exercised
+ * by building the service with a tailored {@link CreditsProperties}. Defaults are {@code
+ * freeProcessingErrors = 2} and {@code ttlMinutes = 60}.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ErrorTrackingServiceTest {
+
+ @Mock private UserErrorTrackerRepository errorTrackerRepository;
+ @Mock private UserRepository userRepository;
+
+ private static final String API_KEY =
+ "test-api-key-0001"; // gitleaks:allow - test fixture, not a secret
+ private static final String ENDPOINT = "/api/v1/convert/pdf-to-img";
+
+ /** Build a CreditsProperties with the given cache + error config. */
+ private static CreditsProperties props(
+ boolean localCacheEnabled, int freeProcessingErrors, int ttlMinutes) {
+ CreditsProperties p = new CreditsProperties();
+ p.getCache().setLocalEnabled(localCacheEnabled);
+ p.getErrors().setFreeProcessingErrors(freeProcessingErrors);
+ p.getErrors().setTtlMinutes(ttlMinutes);
+ return p;
+ }
+
+ private ErrorTrackingService cachedService(int freeProcessingErrors) {
+ return new ErrorTrackingService(
+ errorTrackerRepository, userRepository, props(true, freeProcessingErrors, 60));
+ }
+
+ private ErrorTrackingService dbService(int freeProcessingErrors) {
+ return new ErrorTrackingService(
+ errorTrackerRepository, userRepository, props(false, freeProcessingErrors, 60));
+ }
+
+ private static User user(String username, String apiKey) {
+ User u = new User();
+ u.setUsername(username);
+ u.setApiKey(apiKey);
+ return u;
+ }
+
+ /**
+ * A throwable that classifies as PROCESSING_ERROR when paired with httpStatus 200 and a
+ * non-null endpoint: not a validation/system error, so the endpoint-based processing branch
+ * wins.
+ */
+ private static Throwable processingThrowable() {
+ return new RuntimeException("corrupt pdf stream while rendering page");
+ }
+
+ @Nested
+ @DisplayName("recordErrorAndShouldConsumeCredit - error classification gate")
+ class ClassificationGate {
+
+ @Test
+ @DisplayName("validation error (400) never charges and never touches DB or cache")
+ void validationError_doesNotCharge() {
+ ErrorTrackingService service = cachedService(2);
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY,
+ ENDPOINT,
+ new IllegalArgumentException("missing parameter"),
+ 400);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ verifyNoInteractions(userRepository);
+ }
+
+ @Test
+ @DisplayName("auth error (401) classifies as validation and never charges")
+ void authError_doesNotCharge() {
+ ErrorTrackingService service = cachedService(2);
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, new RuntimeException("denied"), 401);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("system error (500) never charges")
+ void systemError_doesNotCharge() {
+ ErrorTrackingService service = cachedService(2);
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, new RuntimeException("server boom"), 500);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("system exception type (SQLException) at 200 never charges")
+ void systemExceptionType_doesNotCharge() {
+ ErrorTrackingService service = cachedService(2);
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, new SQLException("db down"), 200);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("recordErrorAndShouldConsumeCredit - cache path (freeProcessingErrors=2)")
+ class CachePath {
+
+ @Test
+ @DisplayName("first two processing errors are free; the 3rd charges")
+ void firstTwoFree_thirdCharges() {
+ ErrorTrackingService service = cachedService(2);
+ User u = user("alice", API_KEY);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
+ when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
+ .thenReturn(Optional.empty());
+
+ // count=1 -> not > 2
+ assertThat(call(service)).isFalse();
+ // count=2 -> not > 2
+ assertThat(call(service)).isFalse();
+ // count=3 -> > 2 -> charge, and threshold-crossing persists to DB
+ assertThat(call(service)).isTrue();
+ // count=4 -> still charges, but no second persist (only on the exact crossing)
+ assertThat(call(service)).isTrue();
+
+ // Persisted exactly once: on the threshold-crossing call (count == free + 1).
+ verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
+ }
+
+ @Test
+ @DisplayName("threshold-crossing persists a tracker set to free+1 with future resetAfter")
+ void thresholdCrossing_persistsTrackerWithCorrectCount() {
+ ErrorTrackingService service = cachedService(2);
+ User u = user("bob", API_KEY);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
+ when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
+ .thenReturn(Optional.empty());
+
+ LocalDateTime before = LocalDateTime.now();
+ call(service); // 1
+ call(service); // 2
+ call(service); // 3 -> persist
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(UserErrorTracker.class);
+ verify(errorTrackerRepository).save(captor.capture());
+ UserErrorTracker saved = captor.getValue();
+ assertThat(saved.getProcessingErrorCount()).isEqualTo(3); // free(2) + 1
+ assertThat(saved.getUser()).isSameAs(u);
+ assertThat(saved.getEndpoint()).isEqualTo(ENDPOINT);
+ assertThat(saved.getLastProcessingError()).isNotNull();
+ assertThat(saved.getResetAfter()).isAfter(before);
+ }
+
+ @Test
+ @DisplayName("zero free errors: first processing error charges immediately")
+ void zeroFree_firstErrorCharges() {
+ ErrorTrackingService service = cachedService(0);
+ User u = user("carol", API_KEY);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
+ when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
+ .thenReturn(Optional.empty());
+
+ // count=1 > free(0) -> charge, and 1 == free+1 -> persist
+ assertThat(call(service)).isTrue();
+ verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
+ }
+
+ @Test
+ @DisplayName("distinct endpoints are tracked independently in the cache")
+ void distinctEndpoints_trackedSeparately() {
+ ErrorTrackingService service = cachedService(2);
+
+ // Two errors on endpoint A, two on endpoint B -> neither crosses the free=2 threshold.
+ assertThat(
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, "/a", processingThrowable(), 200))
+ .isFalse();
+ assertThat(
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, "/a", processingThrowable(), 200))
+ .isFalse();
+ assertThat(
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, "/b", processingThrowable(), 200))
+ .isFalse();
+ assertThat(
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, "/b", processingThrowable(), 200))
+ .isFalse();
+
+ // Neither key crossed free+1, so no DB persistence at all.
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("cache path does not blow up if the user is absent at persist time")
+ void cachePersist_userAbsent_swallowsAndStillCharges() {
+ ErrorTrackingService service = cachedService(2);
+ // No user for this key: persistErrorToDatabase finds nothing and saves nothing.
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
+
+ assertThat(call(service)).isFalse(); // 1
+ assertThat(call(service)).isFalse(); // 2
+ assertThat(call(service)).isTrue(); // 3 -> tries persist, user missing -> no save
+
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ /** Convenience: record one processing error on the standard key. */
+ private boolean call(ErrorTrackingService service) {
+ return service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, processingThrowable(), 200);
+ }
+ }
+
+ @Nested
+ @DisplayName("recordErrorAndShouldConsumeCredit - DB fallback path (cache disabled)")
+ class DbFallbackPath {
+
+ @Test
+ @DisplayName("unknown API key returns false and saves nothing")
+ void unknownApiKey_returnsFalse() {
+ ErrorTrackingService service = dbService(2);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, processingThrowable(), 200);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName(
+ "creates a new tracker, records the error and persists; below threshold no charge")
+ void newTracker_belowThreshold_noCharge() {
+ ErrorTrackingService service = dbService(2);
+ User u = user("dave", API_KEY);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
+ when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
+ .thenReturn(Optional.empty());
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, processingThrowable(), 200);
+
+ assertThat(charge).isFalse(); // count 1, free 2
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(UserErrorTracker.class);
+ verify(errorTrackerRepository).save(captor.capture());
+ assertThat(captor.getValue().getProcessingErrorCount()).isEqualTo(1);
+ assertThat(captor.getValue().getUser()).isSameAs(u);
+ }
+
+ @Test
+ @DisplayName("existing tracker already at the threshold rolls over to charging")
+ void existingTracker_atThreshold_charges() {
+ ErrorTrackingService service = dbService(2);
+ User u = user("erin", API_KEY);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
+
+ UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
+ tracker.setProcessingErrorCount(2); // at the free limit
+ when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
+ .thenReturn(Optional.of(tracker));
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, processingThrowable(), 200);
+
+ // recordProcessingError bumps 2 -> 3, which is > free(2) -> charge
+ assertThat(charge).isTrue();
+ assertThat(tracker.getProcessingErrorCount()).isEqualTo(3);
+ verify(errorTrackerRepository).save(tracker);
+ }
+
+ @Test
+ @DisplayName("expired existing tracker is reset before recording the new error")
+ void expiredTracker_isResetThenRecorded() {
+ ErrorTrackingService service = dbService(2);
+ User u = user("finn", API_KEY);
+ when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
+
+ UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
+ tracker.setProcessingErrorCount(5);
+ tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
+ when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
+ .thenReturn(Optional.of(tracker));
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, processingThrowable(), 200);
+
+ // reset to 0, then recordProcessingError -> 1, which is not > free(2)
+ assertThat(charge).isFalse();
+ assertThat(tracker.getProcessingErrorCount()).isEqualTo(1);
+ verify(errorTrackerRepository).save(tracker);
+ }
+ }
+
+ @Nested
+ @DisplayName("hasHighErrorCount")
+ class HasHighErrorCount {
+
+ @Test
+ @DisplayName("returns false when no tracker exists for the key")
+ void noTracker_false() {
+ ErrorTrackingService service = cachedService(2);
+ when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
+ .thenReturn(Optional.empty());
+
+ assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
+ }
+
+ @Test
+ @DisplayName("true when the tracker's count exceeds the free allowance")
+ void aboveFree_true() {
+ ErrorTrackingService service = cachedService(2);
+ UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
+ tracker.setProcessingErrorCount(3); // > 2
+ when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
+ .thenReturn(Optional.of(tracker));
+
+ assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isTrue();
+ }
+
+ @Test
+ @DisplayName("false when the count is exactly at the free allowance (boundary)")
+ void atFree_false() {
+ ErrorTrackingService service = cachedService(2);
+ UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
+ tracker.setProcessingErrorCount(2); // not > 2
+ when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
+ .thenReturn(Optional.of(tracker));
+
+ assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("getErrorInfo")
+ class GetErrorInfo {
+
+ @Test
+ @DisplayName("cache hit reflects the live cached count, remaining free and charging flag")
+ void cacheHit_reportsLiveCount() {
+ ErrorTrackingService service = cachedService(2);
+
+ // Drive the cache to 3 errors on the key so a subsequent getErrorInfo reads it.
+ for (int i = 0; i < 3; i++) {
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, processingThrowable(), 200);
+ }
+
+ ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
+
+ assertThat(info.currentErrorCount).isEqualTo(3);
+ // Math.max(0, free(2) - 3) == 0
+ assertThat(info.errorsUntilCharged).isZero();
+ assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
+ assertThat(info.lastError).isNotNull();
+ }
+
+ @Test
+ @DisplayName("cache miss falls back to an empty/zeroed ErrorInfo when no DB row exists")
+ void cacheMiss_noDbRow_zeroedInfo() {
+ ErrorTrackingService service = cachedService(2);
+ when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
+ .thenReturn(Optional.empty());
+
+ ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
+
+ assertThat(info.currentErrorCount).isZero();
+ assertThat(info.errorsUntilCharged).isEqualTo(2); // full free allowance
+ assertThat(info.isChargingForErrors).isFalse();
+ assertThat(info.lastError).isNull();
+ }
+
+ @Test
+ @DisplayName("DB-backed (cache disabled): live tracker is reported with derived fields")
+ void dbBacked_liveTracker_reported() {
+ ErrorTrackingService service = dbService(2);
+ UserErrorTracker tracker = new UserErrorTracker(user("h", API_KEY), ENDPOINT, 60);
+ tracker.setProcessingErrorCount(3);
+ tracker.setLastProcessingError(LocalDateTime.now());
+ // not expired (constructor set resetAfter ~60m out)
+ when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
+ .thenReturn(Optional.of(tracker));
+
+ ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
+
+ assertThat(info.currentErrorCount).isEqualTo(3);
+ // getErrorsUntilCharged = max(0, free+1 - current) = max(0, 3 - 3) = 0
+ assertThat(info.errorsUntilCharged).isZero();
+ assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
+ assertThat(info.lastError).isNotNull();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("DB-backed: expired tracker is reset, persisted and reported as zeroed")
+ void dbBacked_expiredTracker_resetAndZeroed() {
+ ErrorTrackingService service = dbService(2);
+ UserErrorTracker tracker = new UserErrorTracker(user("i", API_KEY), ENDPOINT, 60);
+ tracker.setProcessingErrorCount(7);
+ tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
+ when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
+ .thenReturn(Optional.of(tracker));
+
+ ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
+
+ assertThat(info.currentErrorCount).isZero();
+ assertThat(info.errorsUntilCharged).isEqualTo(2);
+ assertThat(info.isChargingForErrors).isFalse();
+ assertThat(info.lastError).isNull();
+ assertThat(tracker.getProcessingErrorCount()).isZero(); // reset mutated the entity
+ verify(errorTrackerRepository).save(tracker);
+ }
+ }
+
+ @Nested
+ @DisplayName("cleanupExpiredErrorTrackers")
+ class Cleanup {
+
+ @Test
+ @DisplayName("delegates to the repository delete with a 'now' cutoff")
+ void delegatesDelete() {
+ ErrorTrackingService service = cachedService(2);
+ when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
+ .thenReturn(4);
+
+ service.cleanupExpiredErrorTrackers();
+
+ verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
+ }
+
+ @Test
+ @DisplayName("swallows repository exceptions so the scheduler keeps running")
+ void swallowsRepositoryException() {
+ ErrorTrackingService service = cachedService(2);
+ when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
+ .thenThrow(new RuntimeException("delete blew up"));
+
+ // Must not propagate.
+ service.cleanupExpiredErrorTrackers();
+
+ verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
+ }
+
+ @Test
+ @DisplayName("zero deletions still completes cleanly")
+ void zeroDeletions_ok() {
+ ErrorTrackingService service = cachedService(2);
+ when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
+ .thenReturn(0);
+
+ service.cleanupExpiredErrorTrackers();
+
+ verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
+ }
+ }
+
+ @Nested
+ @DisplayName("ErrorInfo value holder")
+ class ErrorInfoHolder {
+
+ @Test
+ @DisplayName("constructor wires the public fields verbatim")
+ void fieldsWiredVerbatim() {
+ LocalDateTime ts = LocalDateTime.now();
+ ErrorInfo info = new ErrorInfo(5, 1, true, ts);
+
+ assertThat(info.currentErrorCount).isEqualTo(5);
+ assertThat(info.errorsUntilCharged).isEqualTo(1);
+ assertThat(info.isChargingForErrors).isTrue();
+ assertThat(info.lastError).isEqualTo(ts);
+ }
+ }
+
+ @Nested
+ @DisplayName("API key masking is exercised without leaking (smoke via logging branches)")
+ class MaskingSmoke {
+
+ @Test
+ @DisplayName("short API keys are tolerated end-to-end on the cache path")
+ void shortApiKey_tolerated() {
+ ErrorTrackingService service = cachedService(2);
+ when(userRepository.findByApiKey(anyString())).thenReturn(Optional.empty());
+
+ // "key" is < 8 chars, masked as *** inside the service; must not throw on persist path.
+ boolean c1 =
+ service.recordErrorAndShouldConsumeCredit(
+ "key", ENDPOINT, processingThrowable(), 200);
+ boolean c2 =
+ service.recordErrorAndShouldConsumeCredit(
+ "key", ENDPOINT, processingThrowable(), 200);
+ boolean c3 =
+ service.recordErrorAndShouldConsumeCredit(
+ "key", ENDPOINT, processingThrowable(), 200);
+
+ assertThat(c1).isFalse();
+ assertThat(c2).isFalse();
+ assertThat(c3).isTrue();
+ // user absent -> no save even at threshold crossing
+ verify(errorTrackerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("null API key is tolerated on the DB fallback path")
+ void nullApiKey_tolerated() {
+ ErrorTrackingService service = dbService(2);
+ when(userRepository.findByApiKey(eq(null))).thenReturn(Optional.empty());
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ null, ENDPOINT, processingThrowable(), 200);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+ }
+
+ @Test
+ @DisplayName("IOException as a system error type does not charge even at 200")
+ void ioExceptionSystemError_doesNotCharge() {
+ ErrorTrackingService service = cachedService(2);
+
+ boolean charge =
+ service.recordErrorAndShouldConsumeCredit(
+ API_KEY, ENDPOINT, new IOException("disk gone"), 200);
+
+ assertThat(charge).isFalse();
+ verify(errorTrackerRepository, never()).save(any());
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceTest.java
new file mode 100644
index 000000000..7af2fc4a6
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceTest.java
@@ -0,0 +1,229 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link RateLimitService}.
+ *
+ * The service keeps per-team attempt counts in two in-memory {@link
+ * java.util.concurrent.ConcurrentHashMap} buckets (hourly cap 50, daily cap 150) keyed by {@code
+ * "team:" + teamId}. The reset clock is {@link System#currentTimeMillis()} with 1h / 1d windows, so
+ * nothing created inside a test ever expires during the run - every assertion below is
+ * deterministic pure arithmetic with no sleeps or fake clock. A fresh service instance is built per
+ * test so the in-memory buckets start clean.
+ */
+class RateLimitServiceTest {
+
+ private static final int HOURLY_LIMIT = 50;
+ private static final int DAILY_LIMIT = 150;
+
+ private RateLimitService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new RateLimitService();
+ }
+
+ @Nested
+ @DisplayName("allowInvitation - hourly limit")
+ class AllowInvitationHourly {
+
+ @Test
+ @DisplayName("first invitation for a team is allowed")
+ void firstInvitation_allowed() {
+ assertThat(service.allowInvitation(1L)).isTrue();
+ }
+
+ @Test
+ @DisplayName("exactly the hourly limit (50) of invitations are all allowed")
+ void upToHourlyLimit_allAllowed() {
+ for (int i = 1; i <= HOURLY_LIMIT; i++) {
+ assertThat(service.allowInvitation(1L))
+ .as("invitation #%d should be allowed", i)
+ .isTrue();
+ }
+ }
+
+ @Test
+ @DisplayName("the 51st invitation within the hour is rejected")
+ void overHourlyLimit_rejected() {
+ for (int i = 1; i <= HOURLY_LIMIT; i++) {
+ service.allowInvitation(1L);
+ }
+
+ // count would become 51 > 50 -> rejected
+ assertThat(service.allowInvitation(1L)).isFalse();
+ }
+
+ @Test
+ @DisplayName("once over the hourly cap, subsequent attempts stay rejected")
+ void staysRejectedOnceOverHourly() {
+ for (int i = 1; i <= HOURLY_LIMIT + 1; i++) {
+ service.allowInvitation(1L);
+ }
+
+ assertThat(service.allowInvitation(1L)).isFalse();
+ assertThat(service.allowInvitation(1L)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("allowInvitation - per-team isolation")
+ class PerTeamIsolation {
+
+ @Test
+ @DisplayName("different teams have independent counters")
+ void differentTeams_independent() {
+ // Exhaust team 1's hourly quota.
+ for (int i = 1; i <= HOURLY_LIMIT; i++) {
+ service.allowInvitation(1L);
+ }
+ assertThat(service.allowInvitation(1L)).isFalse();
+
+ // Team 2 is untouched and fully allowed.
+ assertThat(service.allowInvitation(2L)).isTrue();
+ assertThat(service.getRemainingInvitations(2L)).isEqualTo(HOURLY_LIMIT - 1);
+ }
+
+ @Test
+ @DisplayName("null teamId is keyed as its own bucket and behaves like any team")
+ void nullTeamId_hasOwnBucket() {
+ assertThat(service.allowInvitation(null)).isTrue();
+ // key becomes "team:null"; remaining drops by one for that key.
+ assertThat(service.getRemainingInvitations(null)).isEqualTo(HOURLY_LIMIT - 1);
+ // A real team is unaffected.
+ assertThat(service.getRemainingInvitations(1L)).isEqualTo(HOURLY_LIMIT);
+ }
+ }
+
+ @Nested
+ @DisplayName("getRemainingInvitations")
+ class GetRemainingInvitations {
+
+ @Test
+ @DisplayName("returns the full hourly allowance when no invitation has been recorded")
+ void noBucket_returnsFullAllowance() {
+ assertThat(service.getRemainingInvitations(7L)).isEqualTo(HOURLY_LIMIT);
+ }
+
+ @Test
+ @DisplayName("decreases by one after a single allowed invitation")
+ void afterOneInvitation_decrementsByOne() {
+ service.allowInvitation(7L);
+
+ assertThat(service.getRemainingInvitations(7L)).isEqualTo(HOURLY_LIMIT - 1);
+ }
+
+ @Test
+ @DisplayName("tracks the running count across several invitations")
+ void tracksRunningCount() {
+ for (int i = 0; i < 10; i++) {
+ service.allowInvitation(7L);
+ }
+
+ assertThat(service.getRemainingInvitations(7L)).isEqualTo(HOURLY_LIMIT - 10);
+ }
+
+ @Test
+ @DisplayName("is zero exactly when the hourly limit has been fully consumed")
+ void atHourlyLimit_remainingIsZero() {
+ for (int i = 1; i <= HOURLY_LIMIT; i++) {
+ service.allowInvitation(7L);
+ }
+
+ assertThat(service.getRemainingInvitations(7L)).isZero();
+ }
+
+ @Test
+ @DisplayName("never goes negative once invitations are rejected past the cap")
+ void overLimit_remainingClampedAtZero() {
+ for (int i = 1; i <= HOURLY_LIMIT + 5; i++) {
+ service.allowInvitation(7L);
+ }
+
+ // Math.max(0, 50 - count) clamps at zero even though attempts exceeded the cap.
+ assertThat(service.getRemainingInvitations(7L)).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("allowInvitation - daily limit and hourly rollback")
+ class DailyLimitAndRollback {
+
+ @Test
+ @DisplayName("daily cap (150) blocks attempts even though it spans multiple hourly windows")
+ void belowDailyLimit_acrossTeams_doesNotInterfere() {
+ // A single team can never reach the daily cap within one hourly window because the
+ // hourly cap (50) trips first. Verify the first 50 are allowed and 51st is blocked,
+ // confirming the hourly gate is the binding constraint here.
+ for (int i = 1; i <= HOURLY_LIMIT; i++) {
+ assertThat(service.allowInvitation(9L)).isTrue();
+ }
+ assertThat(service.allowInvitation(9L)).isFalse();
+ }
+
+ @Test
+ @DisplayName("rejection at the hourly gate does not consume the remaining count further")
+ void hourlyRejection_doesNotAdvanceRemaining() {
+ for (int i = 1; i <= HOURLY_LIMIT; i++) {
+ service.allowInvitation(9L);
+ }
+ assertThat(service.getRemainingInvitations(9L)).isZero();
+
+ // Rejected attempts push the internal count past the cap, but remaining stays clamped.
+ service.allowInvitation(9L);
+ assertThat(service.getRemainingInvitations(9L)).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("cleanupExpiredBuckets")
+ class CleanupExpiredBuckets {
+
+ @Test
+ @DisplayName("runs cleanly when there are no buckets at all")
+ void emptyState_noError() {
+ // Nothing recorded yet; cleanup must be a harmless no-op.
+ service.cleanupExpiredBuckets();
+ }
+
+ @Test
+ @DisplayName("does not evict fresh (unexpired) buckets, preserving their counts")
+ void doesNotEvictFreshBuckets() {
+ service.allowInvitation(11L);
+ service.allowInvitation(11L);
+ assertThat(service.getRemainingInvitations(11L)).isEqualTo(HOURLY_LIMIT - 2);
+
+ // Buckets just created have reset times an hour/day out, so none are expired.
+ service.cleanupExpiredBuckets();
+
+ // Count survived the sweep.
+ assertThat(service.getRemainingInvitations(11L)).isEqualTo(HOURLY_LIMIT - 2);
+ }
+
+ @Test
+ @DisplayName("is idempotent across repeated invocations")
+ void repeatedInvocations_stable() {
+ service.allowInvitation(12L);
+
+ service.cleanupExpiredBuckets();
+ service.cleanupExpiredBuckets();
+ service.cleanupExpiredBuckets();
+
+ assertThat(service.getRemainingInvitations(12L)).isEqualTo(HOURLY_LIMIT - 1);
+ }
+ }
+
+ @Test
+ @DisplayName("daily limit constant is wider than the hourly limit (sanity on configured caps)")
+ void dailyWiderThanHourly() {
+ // This documents the relationship the service relies on: the hourly cap always trips first
+ // for a single uninterrupted burst, so a lone team can't reach the daily cap in one window.
+ assertThat(DAILY_LIMIT).isGreaterThan(HOURLY_LIMIT);
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamExtensionServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamExtensionServiceTest.java
new file mode 100644
index 000000000..d65e41902
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamExtensionServiceTest.java
@@ -0,0 +1,585 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.saas.model.SaasTeamExtensions;
+import stirling.software.saas.repository.SaasTeamExtensionsRepository;
+
+/**
+ * Unit tests for {@link SaasTeamExtensionService}.
+ *
+ *
The service is a thin read/write facade over {@link SaasTeamExtensionsRepository}. Reads
+ * return safe defaults when no row exists (non-personal, STANDARD type, seatsUsed=0, maxSeats=1,
+ * createdBy=null, hasAvailableSeats=true, canInviteMembers=true); writes create the row lazily via
+ * {@code getOrCreate}. Pure delegation + Optional mapping, so everything is mocked at the
+ * repository boundary.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class SaasTeamExtensionServiceTest {
+
+ private static final Long TEAM_ID = 7L;
+
+ @Mock private SaasTeamExtensionsRepository repository;
+
+ @InjectMocks private SaasTeamExtensionService service;
+
+ /** A team with a non-null id (the common case). */
+ private static Team team(Long id) {
+ Team t = new Team();
+ t.setId(id);
+ t.setName("team-" + id);
+ return t;
+ }
+
+ private static Team team() {
+ return team(TEAM_ID);
+ }
+
+ /** A fresh extension row for the given team carrying entity defaults. */
+ private static SaasTeamExtensions ext(Team team) {
+ return new SaasTeamExtensions(team);
+ }
+
+ @Nested
+ @DisplayName("getOrCreate")
+ class GetOrCreate {
+
+ @Test
+ @DisplayName("returns the existing row without creating a new one")
+ void existingRow_returnedWithoutSave() {
+ Team team = team();
+ SaasTeamExtensions existing = ext(team);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(existing));
+
+ SaasTeamExtensions result = service.getOrCreate(team);
+
+ assertThat(result).isSameAs(existing);
+ verify(repository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("lazily creates and saves a new row when none exists")
+ void missingRow_savesNew() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+ // save echoes back its argument so the returned instance is the freshly built row.
+ when(repository.save(any(SaasTeamExtensions.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ SaasTeamExtensions result = service.getOrCreate(team);
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(SaasTeamExtensions.class);
+ verify(repository).save(captor.capture());
+ SaasTeamExtensions saved = captor.getValue();
+ // The new row is bound to the team and carries entity defaults.
+ assertThat(saved.getTeam()).isSameAs(team);
+ assertThat(saved.getTeamId()).isEqualTo(TEAM_ID);
+ assertThat(saved.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
+ assertThat(saved.isPersonal()).isFalse();
+ assertThat(saved.getSeatsUsed()).isZero();
+ assertThat(saved.getMaxSeats()).isEqualTo(1);
+ assertThat(result).isSameAs(saved);
+ }
+ }
+
+ @Nested
+ @DisplayName("isPersonal")
+ class IsPersonal {
+
+ @Test
+ @DisplayName("null team short-circuits to false without touching the repository")
+ void nullTeam_false() {
+ assertThat(service.isPersonal(null)).isFalse();
+ verifyNoInteractions(repository);
+ }
+
+ @Test
+ @DisplayName("team with null id short-circuits to false without touching the repository")
+ void nullId_false() {
+ assertThat(service.isPersonal(team(null))).isFalse();
+ verifyNoInteractions(repository);
+ }
+
+ @Test
+ @DisplayName("no row defaults to false")
+ void noRow_defaultsFalse() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.isPersonal(team)).isFalse();
+ }
+
+ @Test
+ @DisplayName("reflects the persisted personal flag when a row exists")
+ void existingPersonalRow_true() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(true);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.isPersonal(team)).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("getTeamType")
+ class GetTeamType {
+
+ @Test
+ @DisplayName("null team defaults to STANDARD without a lookup")
+ void nullTeam_standard() {
+ assertThat(service.getTeamType(null)).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
+ verifyNoInteractions(repository);
+ }
+
+ @Test
+ @DisplayName("team with null id defaults to STANDARD without a lookup")
+ void nullId_standard() {
+ assertThat(service.getTeamType(team(null)))
+ .isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
+ verifyNoInteractions(repository);
+ }
+
+ @Test
+ @DisplayName("no row defaults to STANDARD")
+ void noRow_standard() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.getTeamType(team)).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
+ }
+
+ @Test
+ @DisplayName("returns the persisted PERSONAL type when a row exists")
+ void existingRow_personalType() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setTeamType(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.getTeamType(team)).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
+ }
+ }
+
+ @Nested
+ @DisplayName("getSeatsUsed")
+ class GetSeatsUsed {
+
+ @Test
+ @DisplayName("no row defaults to 0")
+ void noRow_zero() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.getSeatsUsed(team)).isZero();
+ }
+
+ @Test
+ @DisplayName("returns the persisted seatsUsed value")
+ void existingRow_value() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setSeatsUsed(5);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.getSeatsUsed(team)).isEqualTo(5);
+ }
+ }
+
+ @Nested
+ @DisplayName("getMaxSeats")
+ class GetMaxSeats {
+
+ @Test
+ @DisplayName("no row defaults to 1 (not 0)")
+ void noRow_one() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.getMaxSeats(team)).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("returns the persisted maxSeats value")
+ void existingRow_value() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setMaxSeats(25);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.getMaxSeats(team)).isEqualTo(25);
+ }
+ }
+
+ @Nested
+ @DisplayName("getCreatedByUserId")
+ class GetCreatedByUserId {
+
+ @Test
+ @DisplayName("no row defaults to null")
+ void noRow_null() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.getCreatedByUserId(team)).isNull();
+ }
+
+ @Test
+ @DisplayName("row present but creator unset is null")
+ void existingRow_unsetCreator_null() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(ext(team)));
+
+ assertThat(service.getCreatedByUserId(team)).isNull();
+ }
+
+ @Test
+ @DisplayName("returns the persisted creator id")
+ void existingRow_value() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setCreatedByUserId(99L);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.getCreatedByUserId(team)).isEqualTo(99L);
+ }
+ }
+
+ @Nested
+ @DisplayName("hasAvailableSeats")
+ class HasAvailableSeats {
+
+ @Test
+ @DisplayName("no row defaults to true (optimistic)")
+ void noRow_true() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.hasAvailableSeats(team)).isTrue();
+ }
+
+ @Test
+ @DisplayName("standard team always has seats regardless of usage")
+ void standardTeam_alwaysTrue() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(false);
+ row.setSeatsUsed(1000);
+ row.setMaxSeats(1);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.hasAvailableSeats(team)).isTrue();
+ }
+
+ @Test
+ @DisplayName("personal team with a free seat returns true")
+ void personalTeam_underCap_true() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(true);
+ row.setSeatsUsed(0);
+ row.setMaxSeats(1);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.hasAvailableSeats(team)).isTrue();
+ }
+
+ @Test
+ @DisplayName("personal team at its seat cap returns false (boundary)")
+ void personalTeam_atCap_false() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(true);
+ row.setSeatsUsed(1);
+ row.setMaxSeats(1);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.hasAvailableSeats(team)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("canInviteMembers")
+ class CanInviteMembers {
+
+ @Test
+ @DisplayName("no row defaults to true")
+ void noRow_true() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(service.canInviteMembers(team)).isTrue();
+ }
+
+ @Test
+ @DisplayName("standard team can invite")
+ void standardTeam_true() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(false);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.canInviteMembers(team)).isTrue();
+ }
+
+ @Test
+ @DisplayName("personal team can never invite")
+ void personalTeam_false() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(true);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ assertThat(service.canInviteMembers(team)).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("incrementSeatsUsed")
+ class IncrementSeatsUsed {
+
+ @Test
+ @DisplayName(
+ "ensures the row exists then delegates the atomic increment, returning its result")
+ void existingRow_delegatesIncrement() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(ext(team)));
+ when(repository.incrementSeatsUsed(TEAM_ID)).thenReturn(1);
+
+ int result = service.incrementSeatsUsed(team);
+
+ assertThat(result).isEqualTo(1);
+ verify(repository).incrementSeatsUsed(TEAM_ID);
+ // Row already existed -> no lazy creation.
+ verify(repository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("creates the row first when missing, then increments")
+ void missingRow_createsThenIncrements() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+ when(repository.save(any(SaasTeamExtensions.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+ when(repository.incrementSeatsUsed(TEAM_ID)).thenReturn(1);
+
+ int result = service.incrementSeatsUsed(team);
+
+ assertThat(result).isEqualTo(1);
+ verify(repository).save(any(SaasTeamExtensions.class));
+ verify(repository).incrementSeatsUsed(TEAM_ID);
+ }
+
+ @Test
+ @DisplayName("returns 0 when the atomic update hits the personal-team cap")
+ void capHit_returnsZero() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(ext(team)));
+ when(repository.incrementSeatsUsed(TEAM_ID)).thenReturn(0);
+
+ assertThat(service.incrementSeatsUsed(team)).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("decrementSeatsUsed")
+ class DecrementSeatsUsed {
+
+ @Test
+ @DisplayName("delegates straight to the atomic decrement without creating a row")
+ void delegatesDecrement() {
+ Team team = team();
+ when(repository.decrementSeatsUsed(TEAM_ID)).thenReturn(1);
+
+ int result = service.decrementSeatsUsed(team);
+
+ assertThat(result).isEqualTo(1);
+ verify(repository).decrementSeatsUsed(TEAM_ID);
+ verify(repository, never()).findByTeamId(any());
+ verify(repository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("returns 0 when already floored at zero")
+ void alreadyZero_returnsZero() {
+ Team team = team();
+ when(repository.decrementSeatsUsed(TEAM_ID)).thenReturn(0);
+
+ assertThat(service.decrementSeatsUsed(team)).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("setPersonal")
+ class SetPersonal {
+
+ @Test
+ @DisplayName("marking personal sets the flag and the PERSONAL team type, then saves")
+ void markPersonal_setsFlagAndType() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ service.setPersonal(team, true);
+
+ assertThat(row.isPersonal()).isTrue();
+ assertThat(row.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName(
+ "marking non-personal clears the flag and reverts to STANDARD type, then saves")
+ void markNonPersonal_revertsToStandard() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setIsPersonal(true);
+ row.setTeamType(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ service.setPersonal(team, false);
+
+ assertThat(row.isPersonal()).isFalse();
+ assertThat(row.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("creates the row first when missing, then applies the personal flag")
+ void missingRow_createsThenSets() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+ when(repository.save(any(SaasTeamExtensions.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ service.setPersonal(team, true);
+
+ // getOrCreate saves the new row, then setPersonal saves the mutated row.
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(SaasTeamExtensions.class);
+ verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
+ SaasTeamExtensions last = captor.getValue();
+ assertThat(last.isPersonal()).isTrue();
+ assertThat(last.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
+ }
+ }
+
+ @Nested
+ @DisplayName("setSeats")
+ class SetSeats {
+
+ @Test
+ @DisplayName("writes both seatCount and maxSeats onto the existing row, then saves")
+ void existingRow_writesBoth() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ service.setSeats(team, 3, 10);
+
+ assertThat(row.getSeatCount()).isEqualTo(3);
+ assertThat(row.getMaxSeats()).isEqualTo(10);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("does not touch seatsUsed (only seatCount and maxSeats)")
+ void doesNotChangeSeatsUsed() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setSeatsUsed(4);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ service.setSeats(team, 8, 8);
+
+ assertThat(row.getSeatsUsed()).isEqualTo(4);
+ }
+
+ @Test
+ @DisplayName("creates the row first when missing, then writes the seat fields")
+ void missingRow_createsThenWrites() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+ when(repository.save(any(SaasTeamExtensions.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ service.setSeats(team, 2, 5);
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(SaasTeamExtensions.class);
+ verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
+ SaasTeamExtensions last = captor.getValue();
+ assertThat(last.getSeatCount()).isEqualTo(2);
+ assertThat(last.getMaxSeats()).isEqualTo(5);
+ }
+ }
+
+ @Nested
+ @DisplayName("setCreatedByUserId")
+ class SetCreatedByUserId {
+
+ @Test
+ @DisplayName("writes the creator id onto the existing row, then saves")
+ void existingRow_writesCreator() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ service.setCreatedByUserId(team, 42L);
+
+ assertThat(row.getCreatedByUserId()).isEqualTo(42L);
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("accepts a null creator id (clearing)")
+ void nullCreator_cleared() {
+ Team team = team();
+ SaasTeamExtensions row = ext(team);
+ row.setCreatedByUserId(5L);
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
+
+ service.setCreatedByUserId(team, null);
+
+ assertThat(row.getCreatedByUserId()).isNull();
+ verify(repository).save(row);
+ }
+
+ @Test
+ @DisplayName("creates the row first when missing, then writes the creator id")
+ void missingRow_createsThenWrites() {
+ Team team = team();
+ when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
+ when(repository.save(any(SaasTeamExtensions.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ service.setCreatedByUserId(team, 13L);
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(SaasTeamExtensions.class);
+ verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
+ assertThat(captor.getValue().getCreatedByUserId()).isEqualTo(13L);
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasUserAccountServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasUserAccountServiceTest.java
new file mode 100644
index 000000000..03efa06ab
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/SaasUserAccountServiceTest.java
@@ -0,0 +1,475 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.AuthenticationType;
+import stirling.software.proprietary.security.model.Authority;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.proprietary.security.service.UserService;
+import stirling.software.saas.model.SupabaseUser;
+
+/**
+ * Unit tests for {@link SaasUserAccountService}.
+ *
+ * The service is a thin orchestration layer over {@link UserService} and the saas extension
+ * services; every collaborator is mocked and the methods are invoked directly. Role state is driven
+ * through {@link User#getRolesAsString()} by attaching an {@link Authority} (its constructor
+ * registers itself on the user). Anonymous state is driven through {@link
+ * User#setAuthenticationType(AuthenticationType)}, which the entity stores lowercased so the
+ * service's case-insensitive comparison against {@code ANONYMOUS} matches.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class SaasUserAccountServiceTest {
+
+ @Mock private UserService userService;
+ @Mock private UserRepository userRepository;
+ @Mock private UserRoleService userRoleService;
+ @Mock private SupabaseUserService supabaseUserService;
+ @Mock private SaasUserExtensionService saasUserExtensionService;
+ @Mock private SaasTeamExtensionService saasTeamExtensionService;
+
+ @InjectMocks private SaasUserAccountService service;
+
+ private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
+ private static final UUID SUPABASE_UUID = UUID.fromString(SUPABASE_ID);
+
+ /** Build a user whose role string equals the given role id (e.g. "ROLE_USER"). */
+ private static User userWithRole(String roleId) {
+ User u = new User();
+ u.setUsername("alice@example.com");
+ // Authority's constructor registers itself onto the user's authority set.
+ new Authority(roleId, u);
+ return u;
+ }
+
+ private static Team team(Long id, String name) {
+ Team t = new Team();
+ t.setId(id);
+ t.setName(name);
+ return t;
+ }
+
+ @Nested
+ @DisplayName("getUserBySupabaseId")
+ class GetUserBySupabaseId {
+
+ @Test
+ @DisplayName("returns the local user when the UUID parses and a row exists")
+ void returnsUser() {
+ User u = userWithRole(Role.USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ assertThat(service.getUserBySupabaseId(SUPABASE_ID)).isSameAs(u);
+ }
+
+ @Test
+ @DisplayName("throws with an 'invalid format' message when the id is not a UUID")
+ void invalidFormat_throws() {
+ assertThatThrownBy(() -> service.getUserBySupabaseId("not-a-uuid"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid Supabase ID format")
+ .hasMessageContaining("not-a-uuid");
+
+ verifyNoInteractions(userService);
+ }
+
+ @Test
+ @DisplayName("throws 'user not found' when the UUID parses but no local row exists")
+ void notFound_throws() {
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getUserBySupabaseId(SUPABASE_ID))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("User not found for Supabase ID")
+ .hasMessageContaining(SUPABASE_ID);
+ }
+ }
+
+ @Nested
+ @DisplayName("handleUpgrade")
+ class HandleUpgrade {
+
+ @Test
+ @DisplayName("promotes a free (ROLE_USER) user to PRO and returns true")
+ void freeUser_isUpgraded() {
+ User u = userWithRole(Role.USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ boolean upgraded = service.handleUpgrade(SUPABASE_ID);
+
+ assertThat(upgraded).isTrue();
+ verify(userRoleService).upgradeToPro(u);
+ }
+
+ @Test
+ @DisplayName("returns false and does not re-upgrade a user already on PRO")
+ void proUser_isNoOp() {
+ User u = userWithRole(Role.PRO_USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ boolean upgraded = service.handleUpgrade(SUPABASE_ID);
+
+ assertThat(upgraded).isFalse();
+ verify(userRoleService, never()).upgradeToPro(any());
+ }
+
+ @Test
+ @DisplayName("returns false for any non-free role (e.g. admin/other) without upgrading")
+ void otherRole_isNoOp() {
+ User u = userWithRole("ROLE_ADMIN");
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ assertThat(service.handleUpgrade(SUPABASE_ID)).isFalse();
+ verify(userRoleService, never()).upgradeToPro(any());
+ }
+
+ @Test
+ @DisplayName("propagates the lookup failure when the supabase id has no local user")
+ void unknownUser_propagates() {
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.handleUpgrade(SUPABASE_ID))
+ .isInstanceOf(IllegalArgumentException.class);
+ verifyNoInteractions(userRoleService);
+ }
+ }
+
+ @Nested
+ @DisplayName("handleDowngrade")
+ class HandleDowngrade {
+
+ @Test
+ @DisplayName("downgrades a PRO user with no team to FREE and returns true")
+ void proWithoutTeam_isDowngraded() {
+ User u = userWithRole(Role.PRO_USER.getRoleId());
+ // no team set -> getTeam() is null
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ boolean downgraded = service.handleDowngrade(SUPABASE_ID);
+
+ assertThat(downgraded).isTrue();
+ verify(userRoleService).downgradeToFree(u);
+ }
+
+ @Test
+ @DisplayName(
+ "downgrades a PRO user whose team is personal (personal team is not a shared PRO source)")
+ void proWithPersonalTeam_isDowngraded() {
+ User u = userWithRole(Role.PRO_USER.getRoleId());
+ Team t = team(7L, "alice-personal");
+ u.setTeam(t);
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
+
+ boolean downgraded = service.handleDowngrade(SUPABASE_ID);
+
+ assertThat(downgraded).isTrue();
+ verify(userRoleService).downgradeToFree(u);
+ }
+
+ @Test
+ @DisplayName("keeps PRO (returns false) for a PRO user on a non-personal/shared team")
+ void proWithSharedTeam_keepsPro() {
+ User u = userWithRole(Role.PRO_USER.getRoleId());
+ Team t = team(9L, "acme-team");
+ u.setTeam(t);
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(saasTeamExtensionService.isPersonal(t)).thenReturn(false);
+
+ boolean downgraded = service.handleDowngrade(SUPABASE_ID);
+
+ assertThat(downgraded).isFalse();
+ verify(userRoleService, never()).downgradeToFree(any());
+ }
+
+ @Test
+ @DisplayName("returns false without touching roles when the user is already FREE")
+ void freeUser_isNoOp() {
+ User u = userWithRole(Role.USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ assertThat(service.handleDowngrade(SUPABASE_ID)).isFalse();
+ verify(userRoleService, never()).downgradeToFree(any());
+ verifyNoInteractions(saasTeamExtensionService);
+ }
+ }
+
+ @Nested
+ @DisplayName("enableMeteredBilling")
+ class EnableMeteredBilling {
+
+ @Test
+ @DisplayName("enables metered billing and returns true when it was off")
+ void wasOff_enables() {
+ User u = userWithRole(Role.PRO_USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
+
+ boolean result = service.enableMeteredBilling(SUPABASE_ID);
+
+ assertThat(result).isTrue();
+ verify(saasUserExtensionService).setMeteredBillingEnabled(u, true);
+ }
+
+ @Test
+ @DisplayName("returns false and does not re-enable when metered billing is already on")
+ void alreadyOn_isNoOp() {
+ User u = userWithRole(Role.PRO_USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+
+ boolean result = service.enableMeteredBilling(SUPABASE_ID);
+
+ assertThat(result).isFalse();
+ verify(saasUserExtensionService, never()).setMeteredBillingEnabled(any(), anyBoolean());
+ }
+ }
+
+ @Nested
+ @DisplayName("disableMeteredBilling")
+ class DisableMeteredBilling {
+
+ @Test
+ @DisplayName("disables metered billing and returns true when it was on")
+ void wasOn_disables() {
+ User u = userWithRole(Role.USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
+
+ boolean result = service.disableMeteredBilling(SUPABASE_ID);
+
+ assertThat(result).isTrue();
+ verify(saasUserExtensionService).setMeteredBillingEnabled(u, false);
+ }
+
+ @Test
+ @DisplayName("returns false and does nothing when metered billing is already off")
+ void alreadyOff_isNoOp() {
+ User u = userWithRole(Role.USER.getRoleId());
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
+
+ boolean result = service.disableMeteredBilling(SUPABASE_ID);
+
+ assertThat(result).isFalse();
+ verify(saasUserExtensionService, never()).setMeteredBillingEnabled(any(), anyBoolean());
+ }
+ }
+
+ @Nested
+ @DisplayName("synchronizeUserUpgrade")
+ class SynchronizeUserUpgrade {
+
+ private static SupabaseUser supabaseUser(boolean anonymous) {
+ SupabaseUser su = new SupabaseUser();
+ su.setId(SUPABASE_UUID);
+ su.setAnonymous(anonymous);
+ return su;
+ }
+
+ private static User anonymousLocalUser() {
+ User u = new User();
+ u.setUsername("anon-handle");
+ u.setAuthenticationType(AuthenticationType.ANONYMOUS);
+ return u;
+ }
+
+ @Test
+ @DisplayName("throws IllegalStateException when no local user is linked to the supabase id")
+ void noLinkedUser_throws() {
+ SupabaseUser su = supabaseUser(true);
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(
+ () -> service.synchronizeUserUpgrade(su, "alice@example.com", "google"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("No local user linked to Supabase ID");
+
+ verifyNoInteractions(supabaseUserService);
+ }
+
+ @Test
+ @DisplayName("flips the supabase anonymous mirror to false and saves it")
+ void anonymousMirror_isFlippedAndSaved() {
+ SupabaseUser su = supabaseUser(true);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", "web");
+
+ assertThat(su.isAnonymous()).isFalse();
+ verify(supabaseUserService).save(su);
+ }
+
+ @Test
+ @DisplayName("does not save the supabase mirror when it was already non-anonymous")
+ void nonAnonymousMirror_isNotSaved() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", "web");
+
+ verify(supabaseUserService, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("promotes an anonymous local user to WEB and copies the email into username")
+ void anonymousLocalUser_promotedToWeb() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ User result = service.synchronizeUserUpgrade(su, "alice@example.com", "web");
+
+ assertThat(result).isSameAs(u);
+ // setAuthenticationType stores the enum name lowercased.
+ assertThat(u.getAuthenticationType()).isEqualTo("web");
+ assertThat(u.getEmail()).isEqualTo("alice@example.com");
+ assertThat(u.getUsername()).isEqualTo("alice@example.com");
+ verify(userService).saveUser(u);
+ }
+
+ @Test
+ @DisplayName("maps a known OAuth provider (google) to OAUTH2 auth type")
+ void oauthProvider_mapsToOauth2() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", "google");
+
+ assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
+ }
+
+ @Test
+ @DisplayName("maps a generic 'oauth' authMethod to OAUTH2")
+ void genericOauth_mapsToOauth2() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", "oauth");
+
+ assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
+ }
+
+ @Test
+ @DisplayName("maps an unknown authMethod to WEB")
+ void unknownMethod_mapsToWeb() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", "carrier-pigeon");
+
+ assertThat(u.getAuthenticationType()).isEqualTo("web");
+ }
+
+ @Test
+ @DisplayName("maps a null authMethod to WEB")
+ void nullMethod_mapsToWeb() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", null);
+
+ assertThat(u.getAuthenticationType()).isEqualTo("web");
+ }
+
+ @Test
+ @DisplayName(
+ "does not overwrite username/email when the email is blank, but still promotes the type")
+ void blankEmail_keepsUsername() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, " ", "google");
+
+ assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
+ assertThat(u.getUsername()).isEqualTo("anon-handle");
+ assertThat(u.getEmail()).isNull();
+ verify(userService).saveUser(u);
+ }
+
+ @Test
+ @DisplayName("does not overwrite username/email when the email is null")
+ void nullEmail_keepsUsername() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, null, "web");
+
+ assertThat(u.getUsername()).isEqualTo("anon-handle");
+ assertThat(u.getEmail()).isNull();
+ }
+
+ @Test
+ @DisplayName("leaves a non-anonymous local user untouched and never saves it")
+ void nonAnonymousLocalUser_untouched() {
+ SupabaseUser su = supabaseUser(false);
+ User u = new User();
+ u.setUsername("existing@example.com");
+ u.setAuthenticationType(AuthenticationType.WEB);
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+
+ User result = service.synchronizeUserUpgrade(su, "new@example.com", "google");
+
+ assertThat(result).isSameAs(u);
+ assertThat(u.getUsername()).isEqualTo("existing@example.com");
+ assertThat(u.getAuthenticationType()).isEqualTo("web");
+ verify(userService, never()).saveUser(any());
+ }
+
+ @Test
+ @DisplayName("anonymous comparison is case-insensitive (stored type is lowercased)")
+ void anonymousTypeMatchesCaseInsensitively() {
+ SupabaseUser su = supabaseUser(false);
+ User u = anonymousLocalUser();
+ // sanity: the entity stored the lowercase form, exercising the equalsIgnoreCase branch
+ assertThat(u.getAuthenticationType()).isEqualTo("anonymous");
+ when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
+ when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.synchronizeUserUpgrade(su, "alice@example.com", "web");
+
+ verify(userService).saveUser(u);
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/SupabaseUserServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SupabaseUserServiceTest.java
new file mode 100644
index 000000000..cb3d3635c
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/SupabaseUserServiceTest.java
@@ -0,0 +1,205 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import stirling.software.saas.model.SupabaseUser;
+import stirling.software.saas.model.exception.UserNotFoundException;
+import stirling.software.saas.repository.SupabaseUserRepository;
+
+/**
+ * Unit tests for {@link SupabaseUserService}.
+ *
+ *
Thin CRUD facade over {@link SupabaseUserRepository}. Every method either delegates to the
+ * repository or, in {@code getUser}, translates a {@link Optional#empty()} into a {@link
+ * UserNotFoundException}. The Supabase boundary is fully mocked - no DB, no network.
+ */
+@ExtendWith(MockitoExtension.class)
+class SupabaseUserServiceTest {
+
+ @Mock private SupabaseUserRepository supabaseUserRepository;
+
+ @InjectMocks private SupabaseUserService service;
+
+ private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555");
+ private static final String EMAIL = "user@example.com";
+
+ private static SupabaseUser supabaseUser(UUID id, String email, boolean anonymous) {
+ SupabaseUser u = new SupabaseUser();
+ u.setId(id);
+ u.setEmail(email);
+ u.setAnonymous(anonymous);
+ return u;
+ }
+
+ @Nested
+ @DisplayName("getUser")
+ class GetUser {
+
+ @Test
+ @DisplayName("returns the entity when the repository finds it by id")
+ void found_returnsEntity() {
+ SupabaseUser existing = supabaseUser(SUPABASE_ID, EMAIL, false);
+ when(supabaseUserRepository.findById(SUPABASE_ID)).thenReturn(Optional.of(existing));
+
+ SupabaseUser result = service.getUser(SUPABASE_ID);
+
+ assertThat(result).isSameAs(existing);
+ verify(supabaseUserRepository).findById(SUPABASE_ID);
+ }
+
+ @Test
+ @DisplayName("throws UserNotFoundException carrying the id when the repository is empty")
+ void missing_throwsUserNotFound() {
+ when(supabaseUserRepository.findById(SUPABASE_ID)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getUser(SUPABASE_ID))
+ .isInstanceOf(UserNotFoundException.class)
+ .hasMessageContaining(SUPABASE_ID.toString())
+ .hasMessageContaining("not found");
+
+ verify(supabaseUserRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("looks the user up by the exact id it is given")
+ void passesIdThrough() {
+ UUID other = UUID.fromString("99999999-8888-7777-6666-555555555555");
+ when(supabaseUserRepository.findById(other))
+ .thenReturn(Optional.of(supabaseUser(other, "other@example.com", true)));
+
+ SupabaseUser result = service.getUser(other);
+
+ assertThat(result.getId()).isEqualTo(other);
+ verify(supabaseUserRepository).findById(other);
+ }
+ }
+
+ @Nested
+ @DisplayName("createSupabaseUser")
+ class CreateSupabaseUser {
+
+ @Test
+ @DisplayName("builds a SupabaseUser from the args and returns the saved entity")
+ void buildsAndSavesUser() {
+ // Repository echoes whatever it was handed.
+ when(supabaseUserRepository.save(any(SupabaseUser.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ SupabaseUser result = service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SupabaseUser.class);
+ verify(supabaseUserRepository).save(captor.capture());
+ SupabaseUser saved = captor.getValue();
+ assertThat(saved.getId()).isEqualTo(SUPABASE_ID);
+ assertThat(saved.getEmail()).isEqualTo(EMAIL);
+ assertThat(saved.isAnonymous()).isFalse();
+ // The method returns exactly what the repository produced.
+ assertThat(result).isSameAs(saved);
+ }
+
+ @Test
+ @DisplayName("propagates the anonymous flag when true")
+ void anonymousFlagTrue() {
+ when(supabaseUserRepository.save(any(SupabaseUser.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ service.createSupabaseUser(SUPABASE_ID, EMAIL, true);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SupabaseUser.class);
+ verify(supabaseUserRepository).save(captor.capture());
+ assertThat(captor.getValue().isAnonymous()).isTrue();
+ }
+
+ @Test
+ @DisplayName("accepts a null email and persists it unchanged (no normalisation)")
+ void nullEmail_persistedAsNull() {
+ when(supabaseUserRepository.save(any(SupabaseUser.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ service.createSupabaseUser(SUPABASE_ID, null, false);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(SupabaseUser.class);
+ verify(supabaseUserRepository).save(captor.capture());
+ assertThat(captor.getValue().getEmail()).isNull();
+ }
+
+ @Test
+ @DisplayName("returns the repository's instance, not a freshly built one")
+ void returnsRepositoryInstance() {
+ SupabaseUser persisted = supabaseUser(SUPABASE_ID, EMAIL, false);
+ when(supabaseUserRepository.save(any(SupabaseUser.class))).thenReturn(persisted);
+
+ SupabaseUser result = service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
+
+ assertThat(result).isSameAs(persisted);
+ }
+
+ @Test
+ @DisplayName("does not read the user back before creating it")
+ void doesNotFindBeforeSave() {
+ when(supabaseUserRepository.save(any(SupabaseUser.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
+
+ verify(supabaseUserRepository, never()).findById(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("save")
+ class Save {
+
+ @Test
+ @DisplayName("delegates straight to the repository and returns its result")
+ void delegatesToRepository() {
+ SupabaseUser input = supabaseUser(SUPABASE_ID, EMAIL, false);
+ SupabaseUser persisted = supabaseUser(SUPABASE_ID, EMAIL, false);
+ when(supabaseUserRepository.save(input)).thenReturn(persisted);
+
+ SupabaseUser result = service.save(input);
+
+ assertThat(result).isSameAs(persisted);
+ verify(supabaseUserRepository).save(input);
+ }
+
+ @Test
+ @DisplayName("passes a null entity through to the repository without guarding")
+ void nullEntity_passedThrough() {
+ when(supabaseUserRepository.save(null)).thenReturn(null);
+
+ SupabaseUser result = service.save(null);
+
+ assertThat(result).isNull();
+ verify(supabaseUserRepository).save(null);
+ }
+ }
+
+ @Test
+ @DisplayName("repository save failures bubble out of createSupabaseUser unchanged")
+ void createSupabaseUser_repositoryThrows_propagates() {
+ when(supabaseUserRepository.save(any(SupabaseUser.class)))
+ .thenThrow(new RuntimeException("constraint violation"));
+
+ assertThatThrownBy(() -> service.createSupabaseUser(SUPABASE_ID, EMAIL, false))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("constraint violation");
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java
new file mode 100644
index 000000000..1288a0bca
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java
@@ -0,0 +1,612 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.slf4j.MDC;
+
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.billing.service.StripeUsageReportingService;
+import stirling.software.saas.config.CreditsProperties;
+import stirling.software.saas.model.CreditConsumptionResult;
+import stirling.software.saas.model.TeamCredit;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.repository.TeamCreditRepository;
+import stirling.software.saas.repository.TeamMembershipRepository;
+
+/**
+ * Unit tests for {@link TeamCreditService}. Collaborators (repositories, billing/extension
+ * services) are mocked; credit math is pure arithmetic so exact numbers are asserted. The waterfall
+ * path is exercised across every branch: pool hit, no leader, metered-billing disabled, missing
+ * Supabase id, Stripe report success/failure, and a thrown exception during reporting.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class TeamCreditServiceTest {
+
+ @Mock private TeamCreditRepository teamCreditRepository;
+ @Mock private TeamMembershipRepository membershipRepository;
+ @Mock private StripeUsageReportingService stripeUsageReportingService;
+ @Mock private SaasUserExtensionService saasUserExtensionService;
+
+ // Real CreditsProperties carries the production default allocations (ROLE_PRO_USER -> 500).
+ private final CreditsProperties creditsProperties = new CreditsProperties();
+
+ private TeamCreditService service;
+
+ @BeforeEach
+ void setUp() {
+ service =
+ new TeamCreditService(
+ teamCreditRepository,
+ membershipRepository,
+ creditsProperties,
+ stripeUsageReportingService,
+ saasUserExtensionService);
+ }
+
+ @AfterEach
+ void clearMdc() {
+ MDC.clear();
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Fixtures
+ // ----------------------------------------------------------------------------------------
+
+ private static Team team(Long id) {
+ Team t = new Team();
+ t.setId(id);
+ return t;
+ }
+
+ private static User user(Long id, String username) {
+ User u = new User();
+ u.setId(id);
+ u.setUsername(username);
+ return u;
+ }
+
+ private static TeamCredit credit(int cycleRemaining, int boughtRemaining) {
+ TeamCredit c = new TeamCredit();
+ c.setCycleCreditsRemaining(cycleRemaining);
+ c.setCycleCreditsAllocated(cycleRemaining);
+ c.setBoughtCreditsRemaining(boughtRemaining);
+ c.setTotalBoughtCredits(boughtRemaining);
+ return c;
+ }
+
+ private static TeamMembership leaderMembership(User leader) {
+ TeamMembership m = new TeamMembership();
+ m.setRole(TeamRole.LEADER);
+ m.setUser(leader);
+ return m;
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // initializeTeamCredits
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("initializeTeamCredits")
+ class InitializeTeamCredits {
+
+ @Test
+ @DisplayName("returns the existing row without saving when credits already exist")
+ void returnsExistingWithoutSaving() {
+ Team team = team(100L);
+ TeamCredit existing = credit(123, 0);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(existing));
+
+ TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary"));
+
+ assertThat(result).isSameAs(existing);
+ verify(teamCreditRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("seeds the fixed PRO allocation (500) into both cycle fields for a new team")
+ void seedsFixedProAllocation() {
+ Team team = team(100L);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+ when(teamCreditRepository.save(any(TeamCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary"));
+
+ // 500 is the production ROLE_PRO_USER default in CreditsProperties.
+ assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
+ assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
+ assertThat(result.getLastCycleResetAt()).isNotNull();
+ assertThat(result.getTeam()).isSameAs(team);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(TeamCredit.class);
+ verify(teamCreditRepository).save(captor.capture());
+ assertThat(captor.getValue().getCycleCreditsRemaining()).isEqualTo(500);
+ }
+
+ @Test
+ @DisplayName("honours a custom ROLE_PRO_USER allocation from properties")
+ void honoursCustomAllocation() {
+ CreditsProperties custom = new CreditsProperties();
+ custom.getCycle().setAllocations(Map.of("ROLE_PRO_USER", 750));
+ TeamCreditService svc =
+ new TeamCreditService(
+ teamCreditRepository,
+ membershipRepository,
+ custom,
+ stripeUsageReportingService,
+ saasUserExtensionService);
+ Team team = team(100L);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+ when(teamCreditRepository.save(any(TeamCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary"));
+
+ assertThat(result.getCycleCreditsAllocated()).isEqualTo(750);
+ assertThat(result.getCycleCreditsRemaining()).isEqualTo(750);
+ }
+
+ @Test
+ @DisplayName("falls back to 500 when the allocation map has no ROLE_PRO_USER entry")
+ void fallsBackTo500WhenKeyMissing() {
+ CreditsProperties custom = new CreditsProperties();
+ custom.getCycle().setAllocations(Map.of("ROLE_USER", 50));
+ TeamCreditService svc =
+ new TeamCreditService(
+ teamCreditRepository,
+ membershipRepository,
+ custom,
+ stripeUsageReportingService,
+ saasUserExtensionService);
+ Team team = team(100L);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+ when(teamCreditRepository.save(any(TeamCredit.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary"));
+
+ assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
+ assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // hasCreditsAvailable
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("hasCreditsAvailable")
+ class HasCreditsAvailable {
+
+ @Test
+ @DisplayName("true when the team pool has cycle or bought credits left")
+ void trueWhenCreditsRemain() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(3, 0)));
+ assertThat(service.hasCreditsAvailable(100L)).isTrue();
+ }
+
+ @Test
+ @DisplayName("true when only bought credits remain")
+ void trueWhenOnlyBoughtRemain() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 5)));
+ assertThat(service.hasCreditsAvailable(100L)).isTrue();
+ }
+
+ @Test
+ @DisplayName("false when the pool is empty")
+ void falseWhenPoolEmpty() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 0)));
+ assertThat(service.hasCreditsAvailable(100L)).isFalse();
+ }
+
+ @Test
+ @DisplayName("false when no credit row exists for the team")
+ void falseWhenNoRow() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+ assertThat(service.hasCreditsAvailable(100L)).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // consumeCredit
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("consumeCredit")
+ class ConsumeCredit {
+
+ @Test
+ @DisplayName("true when the atomic update affected a row")
+ void trueWhenRowUpdated() {
+ when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(1);
+ assertThat(service.consumeCredit(100L, 2)).isTrue();
+ verify(teamCreditRepository).consumeCredit(100L, 2);
+ }
+
+ @Test
+ @DisplayName("false when the atomic update affected no rows (insufficient / conflict)")
+ void falseWhenNoRowUpdated() {
+ when(teamCreditRepository.consumeCredit(100L, 5)).thenReturn(0);
+ assertThat(service.consumeCredit(100L, 5)).isFalse();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // getCreditSummaryForUser
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("getCreditSummaryForUser")
+ class GetCreditSummaryForUser {
+
+ @Test
+ @DisplayName("empty when the user has no team assigned")
+ void emptyWhenNoTeam() {
+ User u = user(7L, "noteam");
+ u.setTeam(null);
+
+ Optional result = service.getCreditSummaryForUser(u);
+
+ assertThat(result).isEmpty();
+ verifyNoInteractions(teamCreditRepository);
+ }
+
+ @Test
+ @DisplayName("delegates to the repository for the user's team id")
+ void delegatesToRepository() {
+ User u = user(7L, "withteam");
+ u.setTeam(team(200L));
+ TeamCredit row = credit(10, 0);
+ when(teamCreditRepository.findByTeamId(200L)).thenReturn(Optional.of(row));
+
+ Optional result = service.getCreditSummaryForUser(u);
+
+ assertThat(result).containsSame(row);
+ verify(teamCreditRepository).findByTeamId(200L);
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // getTeamCredits
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("getTeamCredits")
+ class GetTeamCredits {
+
+ @Test
+ @DisplayName("passes through the repository optional when present")
+ void presentPassesThrough() {
+ TeamCredit row = credit(5, 5);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
+ assertThat(service.getTeamCredits(100L)).containsSame(row);
+ }
+
+ @Test
+ @DisplayName("empty when the repository has no row")
+ void emptyWhenAbsent() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+ assertThat(service.getTeamCredits(100L)).isEmpty();
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // addBoughtCredits
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("addBoughtCredits")
+ class AddBoughtCredits {
+
+ @Test
+ @DisplayName("adds to bought and total counters then saves")
+ void addsAndSaves() {
+ TeamCredit row = credit(0, 10);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
+
+ service.addBoughtCredits(100L, 25);
+
+ assertThat(row.getBoughtCreditsRemaining()).isEqualTo(35);
+ assertThat(row.getTotalBoughtCredits()).isEqualTo(35);
+ verify(teamCreditRepository).save(row);
+ }
+
+ @Test
+ @DisplayName("throws IllegalArgumentException when no credit row exists")
+ void throwsWhenMissing() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.addBoughtCredits(100L, 25))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Team credits not found");
+ verify(teamCreditRepository, never()).save(any());
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // resetCycleCredits
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("resetCycleCredits")
+ class ResetCycleCredits {
+
+ @Test
+ @DisplayName("overwrites cycle allocation/remaining and reset timestamp then saves")
+ void resetsAndSaves() {
+ TeamCredit row = credit(1, 0);
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
+ LocalDateTime resetTime = LocalDateTime.of(2026, 1, 1, 2, 0);
+
+ service.resetCycleCredits(100L, 600, resetTime);
+
+ assertThat(row.getCycleCreditsAllocated()).isEqualTo(600);
+ assertThat(row.getCycleCreditsRemaining()).isEqualTo(600);
+ assertThat(row.getLastCycleResetAt()).isEqualTo(resetTime);
+ verify(teamCreditRepository).save(row);
+ }
+
+ @Test
+ @DisplayName("throws IllegalArgumentException when no credit row exists")
+ void throwsWhenMissing() {
+ when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.resetCycleCredits(100L, 600, LocalDateTime.now()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Team credits not found");
+ verify(teamCreditRepository, never()).save(any());
+ }
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // consumeCreditWithWaterfall
+ // ----------------------------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("consumeCreditWithWaterfall")
+ class ConsumeCreditWithWaterfall {
+
+ @Test
+ @DisplayName("pool hit returns success(TEAM_CREDITS) and never touches overage billing")
+ void poolHitShortCircuits() {
+ when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(1);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("TEAM_CREDITS");
+ verifyNoInteractions(membershipRepository);
+ verifyNoInteractions(saasUserExtensionService);
+ verifyNoInteractions(stripeUsageReportingService);
+ }
+
+ @Test
+ @DisplayName("pool exhausted and no leader returns failure(NO_TEAM_LEADER)")
+ void poolExhaustedNoLeader() {
+ when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of());
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
+
+ assertThat(result.isSuccess()).isFalse();
+ // failure(reason) sets the message to the reason code.
+ assertThat(result.getMessage()).isEqualTo("NO_TEAM_LEADER");
+ verifyNoInteractions(saasUserExtensionService);
+ verifyNoInteractions(stripeUsageReportingService);
+ }
+
+ @Test
+ @DisplayName("leader without metered billing returns the no-overage failure message")
+ void leaderMeteredBillingDisabled() {
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(UUID.randomUUID());
+ when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(false);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("Team credits exhausted");
+ verifyNoInteractions(stripeUsageReportingService);
+ }
+
+ @Test
+ @DisplayName("leader with metered billing but no Supabase id returns LEADER_NO_SUPABASE_ID")
+ void leaderMissingSupabaseId() {
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(null);
+ when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).isEqualTo("LEADER_NO_SUPABASE_ID");
+ verifyNoInteractions(stripeUsageReportingService);
+ }
+
+ @Test
+ @DisplayName("successful Stripe overage report returns success(TEAM_LEADER_METERED)")
+ void overageReportedSuccessfully() {
+ UUID supabaseId = UUID.randomUUID();
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(supabaseId);
+ when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ eq(supabaseId.toString()), eq(4), anyString()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(
+ supabaseId.toString(), 4, "idem-key"))
+ .thenReturn(true);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.getSource()).isEqualTo("TEAM_LEADER_METERED");
+ verify(stripeUsageReportingService)
+ .reportUsageToStripe(supabaseId.toString(), 4, "idem-key");
+ }
+
+ @Test
+ @DisplayName("uses the MDC requestId as the stable operation id for the idempotency key")
+ void usesMdcRequestIdForIdempotency() {
+ UUID supabaseId = UUID.randomUUID();
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(supabaseId);
+ MDC.put("requestId", "req-123");
+ when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ anyString(), anyInt(), anyString()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(
+ anyString(), anyInt(), anyString()))
+ .thenReturn(true);
+
+ service.consumeCreditWithWaterfall(100L, 2);
+
+ verify(stripeUsageReportingService)
+ .generateIdempotencyKey(supabaseId.toString(), 2, "req-123");
+ }
+
+ @Test
+ @DisplayName("falls back to a random operation id when no requestId is in MDC")
+ void generatesRandomOperationIdWhenNoMdc() {
+ UUID supabaseId = UUID.randomUUID();
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(supabaseId);
+ // No MDC requestId set -> a random UUID string is generated.
+ when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ anyString(), anyInt(), anyString()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(
+ anyString(), anyInt(), anyString()))
+ .thenReturn(true);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 2);
+
+ assertThat(result.isSuccess()).isTrue();
+ ArgumentCaptor opId = ArgumentCaptor.forClass(String.class);
+ verify(stripeUsageReportingService)
+ .generateIdempotencyKey(eq(supabaseId.toString()), eq(2), opId.capture());
+ assertThat(opId.getValue()).isNotBlank();
+ }
+
+ @Test
+ @DisplayName("Stripe report returning false yields failure(STRIPE_REPORTING_FAILED)")
+ void overageReportFailed() {
+ UUID supabaseId = UUID.randomUUID();
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(supabaseId);
+ when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ anyString(), anyInt(), anyString()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(
+ anyString(), anyInt(), anyString()))
+ .thenReturn(false);
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("Unable to report usage to Stripe");
+ }
+
+ @Test
+ @DisplayName("exception during reporting is caught and surfaced as STRIPE_REPORTING_ERROR")
+ void overageReportThrows() {
+ UUID supabaseId = UUID.randomUUID();
+ User leader = user(9L, "leader");
+ leader.setSupabaseId(supabaseId);
+ when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(leader)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ anyString(), anyInt(), anyString()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(
+ anyString(), anyInt(), anyString()))
+ .thenThrow(new RuntimeException("boom"));
+
+ CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
+
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(result.getMessage()).contains("Error reporting usage: boom");
+ }
+
+ @Test
+ @DisplayName("picks the first leader when several leaders exist on the team")
+ void picksFirstLeaderOfMany() {
+ UUID firstSupabaseId = UUID.randomUUID();
+ User first = user(1L, "first-leader");
+ first.setSupabaseId(firstSupabaseId);
+ User second = user(2L, "second-leader");
+ second.setSupabaseId(UUID.randomUUID());
+ when(teamCreditRepository.consumeCredit(100L, 1)).thenReturn(0);
+ when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
+ .thenReturn(List.of(leaderMembership(first), leaderMembership(second)));
+ when(saasUserExtensionService.isMeteredBillingEnabled(first)).thenReturn(true);
+ when(stripeUsageReportingService.generateIdempotencyKey(
+ anyString(), anyInt(), anyString()))
+ .thenReturn("idem-key");
+ when(stripeUsageReportingService.reportUsageToStripe(
+ anyString(), anyInt(), anyString()))
+ .thenReturn(true);
+
+ service.consumeCreditWithWaterfall(100L, 1);
+
+ // Only the first leader's identity is checked / reported on.
+ verify(saasUserExtensionService).isMeteredBillingEnabled(first);
+ verify(saasUserExtensionService, never()).isMeteredBillingEnabled(second);
+ verify(stripeUsageReportingService)
+ .generateIdempotencyKey(eq(firstSupabaseId.toString()), eq(1), anyString());
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java
new file mode 100644
index 000000000..d85c6be8f
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java
@@ -0,0 +1,378 @@
+package stirling.software.saas.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.proprietary.security.database.repository.AuthorityRepository;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.Authority;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.config.CreditsProperties;
+
+/**
+ * Unit tests for {@link UserRoleService}.
+ *
+ * The service is a thin orchestrator: it flips a user's {@link Authority} row, mirrors the role
+ * into the denormalized {@code roleName} column, and (for the upgrade/downgrade helpers) resets the
+ * cycle credit allocation via {@link CreditService}. All collaborators are mocked; allocation math
+ * is pure arithmetic and asserted exactly.
+ *
+ *
Allocation note: {@link CreditsProperties}'s default map already carries ROLE_USER=50 and
+ * ROLE_PRO_USER=500, so {@code getOrDefault} returns those, NOT the inline 25/100 fallbacks baked
+ * into the service. The inline fallbacks only surface when the allocations map lacks the key, which
+ * is exercised separately with an emptied map.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class UserRoleServiceTest {
+
+ @Mock private UserRepository userRepository;
+ @Mock private AuthorityRepository authorityRepository;
+ @Mock private CreditService creditService;
+
+ private static final String ROLE_USER = Role.USER.getRoleId(); // "ROLE_USER"
+ private static final String ROLE_PRO_USER = Role.PRO_USER.getRoleId(); // "ROLE_PRO_USER"
+
+ /** Build the service with the supplied properties (allocations differ per test). */
+ private UserRoleService service(CreditsProperties props) {
+ return new UserRoleService(userRepository, authorityRepository, creditService, props);
+ }
+
+ /** Default properties: allocations map carries ROLE_USER=50, ROLE_PRO_USER=500. */
+ private static CreditsProperties defaultProps() {
+ return new CreditsProperties();
+ }
+
+ /** Properties whose allocations map is empty, forcing the service's inline 25/100 fallbacks. */
+ private static CreditsProperties emptyAllocationsProps() {
+ CreditsProperties p = new CreditsProperties();
+ p.getCycle().setAllocations(new HashMap<>());
+ return p;
+ }
+
+ /** Properties whose allocations map holds the exact values we want to assert against. */
+ private static CreditsProperties propsWith(Map allocations) {
+ CreditsProperties p = new CreditsProperties();
+ p.getCycle().setAllocations(new HashMap<>(allocations));
+ return p;
+ }
+
+ private static User user(long id, String username, String currentRole) {
+ User u = new User();
+ u.setId(id);
+ u.setUsername(username);
+ u.setRoleName(currentRole);
+ return u;
+ }
+
+ private static Authority authority(String currentRole) {
+ Authority a = new Authority();
+ a.setId(7L);
+ a.setAuthority(currentRole);
+ return a;
+ }
+
+ /**
+ * Reads the denormalized {@code roleName} column straight off the field. {@link
+ * User#getRoleName()} is overridden to derive the role from the authorities set (via {@link
+ * Role#fromString}), so it cannot observe the column that {@code changeRole} mirrors via {@code
+ * setRoleName}.
+ */
+ private static String mirroredRoleName(User u) {
+ return (String) ReflectionTestUtils.getField(u, "roleName");
+ }
+
+ @Nested
+ @DisplayName("changeRole")
+ class ChangeRole {
+
+ @Test
+ @DisplayName("flips the Authority row, mirrors roleName, and persists both")
+ void flipsAuthorityAndMirrorsRoleName() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "alice@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.changeRole(u, ROLE_PRO_USER);
+
+ // Authority entity carries the new role and is saved.
+ assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
+ verify(authorityRepository).save(auth);
+ // Denormalized column mirrored on the User entity and saved.
+ assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
+ verify(userRepository).save(u);
+ // No credit reset on the bare changeRole path.
+ verify(creditService, never())
+ .resetCycleAllocationForRoleChange(Mockito.anyLong(), Mockito.anyInt());
+ }
+
+ @Test
+ @DisplayName("persists the authority before the user (authority-first ordering)")
+ void persistsAuthorityBeforeUser() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "alice@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.changeRole(u, ROLE_PRO_USER);
+
+ InOrder order = Mockito.inOrder(authorityRepository, userRepository);
+ order.verify(authorityRepository).save(auth);
+ order.verify(userRepository).save(u);
+ }
+
+ @Test
+ @DisplayName("looks the authority up by the user's numeric id")
+ void looksUpAuthorityByUserId() {
+ UserRoleService service = service(defaultProps());
+ User u = user(99L, "bob@example.com", ROLE_PRO_USER);
+ Authority auth = authority(ROLE_PRO_USER);
+ when(authorityRepository.findByUserId(99L)).thenReturn(auth);
+
+ service.changeRole(u, ROLE_USER);
+
+ verify(authorityRepository).findByUserId(99L);
+ assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
+ assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
+ }
+
+ @Test
+ @DisplayName("setting the same role is a harmless no-op rewrite that still persists")
+ void sameRoleStillPersists() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "carol@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.changeRole(u, ROLE_USER);
+
+ assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
+ verify(authorityRepository).save(auth);
+ verify(userRepository).save(u);
+ }
+
+ @Test
+ @DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")")
+ void emptyAuthoritiesSetIsTolerated() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "dave@example.com", null);
+ // getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log.
+ Authority auth = authority(null);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.changeRole(u, ROLE_PRO_USER);
+
+ assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
+ assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
+ }
+ }
+
+ @Nested
+ @DisplayName("downgradeToFree")
+ class DowngradeToFree {
+
+ @Test
+ @DisplayName("sets ROLE_USER and resets credits to the configured FREE allocation (50)")
+ void setsUserRoleAndResetsCreditsFromConfig() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "eve@example.com", ROLE_PRO_USER);
+ Authority auth = authority(ROLE_PRO_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.downgradeToFree(u);
+
+ assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
+ assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
+ verify(authorityRepository).save(auth);
+ verify(userRepository).save(u);
+ // Config map has ROLE_USER=50, so getOrDefault returns 50 (NOT the inline 25 fallback).
+ verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
+ }
+
+ @Test
+ @DisplayName("uses the inline 25 fallback when the allocations map omits ROLE_USER")
+ void usesInlineFallbackWhenAllocationMissing() {
+ UserRoleService service = service(emptyAllocationsProps());
+ User u = user(42L, "frank@example.com", ROLE_PRO_USER);
+ Authority auth = authority(ROLE_PRO_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.downgradeToFree(u);
+
+ verify(creditService).resetCycleAllocationForRoleChange(42L, 25);
+ }
+
+ @Test
+ @DisplayName("honours an explicit ROLE_USER allocation override")
+ void honoursExplicitAllocationOverride() {
+ UserRoleService service = service(propsWith(Map.of(ROLE_USER, 7)));
+ User u = user(42L, "grace@example.com", ROLE_PRO_USER);
+ Authority auth = authority(ROLE_PRO_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.downgradeToFree(u);
+
+ verify(creditService).resetCycleAllocationForRoleChange(42L, 7);
+ }
+
+ @Test
+ @DisplayName("changes the role before resetting credits (role flip precedes the reset)")
+ void changesRoleBeforeResettingCredits() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "heidi@example.com", ROLE_PRO_USER);
+ Authority auth = authority(ROLE_PRO_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.downgradeToFree(u);
+
+ InOrder order = Mockito.inOrder(userRepository, creditService);
+ order.verify(userRepository).save(u);
+ order.verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
+ }
+ }
+
+ @Nested
+ @DisplayName("upgradeToPro")
+ class UpgradeToPro {
+
+ @Test
+ @DisplayName("sets ROLE_PRO_USER and resets credits to the configured PRO allocation (500)")
+ void setsProRoleAndResetsCreditsFromConfig() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "ivan@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.upgradeToPro(u);
+
+ assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
+ assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
+ verify(authorityRepository).save(auth);
+ verify(userRepository).save(u);
+ // Config map has ROLE_PRO_USER=500, so getOrDefault returns 500 (NOT inline 100).
+ verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
+ }
+
+ @Test
+ @DisplayName("uses the inline 100 fallback when the allocations map omits ROLE_PRO_USER")
+ void usesInlineFallbackWhenAllocationMissing() {
+ UserRoleService service = service(emptyAllocationsProps());
+ User u = user(42L, "judy@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.upgradeToPro(u);
+
+ verify(creditService).resetCycleAllocationForRoleChange(42L, 100);
+ }
+
+ @Test
+ @DisplayName("honours an explicit ROLE_PRO_USER allocation override")
+ void honoursExplicitAllocationOverride() {
+ UserRoleService service = service(propsWith(Map.of(ROLE_PRO_USER, 9999)));
+ User u = user(42L, "mallory@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.upgradeToPro(u);
+
+ verify(creditService).resetCycleAllocationForRoleChange(42L, 9999);
+ }
+
+ @Test
+ @DisplayName("changes the role before resetting credits (role flip precedes the reset)")
+ void changesRoleBeforeResettingCredits() {
+ UserRoleService service = service(defaultProps());
+ User u = user(42L, "niaj@example.com", ROLE_USER);
+ Authority auth = authority(ROLE_USER);
+ when(authorityRepository.findByUserId(42L)).thenReturn(auth);
+
+ service.upgradeToPro(u);
+
+ InOrder order = Mockito.inOrder(userRepository, creditService);
+ order.verify(userRepository).save(u);
+ order.verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
+ }
+ }
+
+ @Nested
+ @DisplayName("getCreditAllocationForRole")
+ class GetCreditAllocationForRole {
+
+ @Test
+ @DisplayName("returns the configured allocation when the role is present in the map")
+ void returnsConfiguredAllocation() {
+ UserRoleService service = service(defaultProps());
+
+ // Default map: ROLE_USER=50, ROLE_PRO_USER=500, ROLE_ADMIN=1000.
+ assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(50);
+ assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(500);
+ assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(1000);
+ }
+
+ @Test
+ @DisplayName("ROLE_USER falls back to 25 when missing from the allocations map")
+ void userRoleFallsBackTo25() {
+ UserRoleService service = service(emptyAllocationsProps());
+
+ assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(25);
+ }
+
+ @Test
+ @DisplayName("any non-ROLE_USER role falls back to 100 when missing from the map")
+ void nonUserRoleFallsBackTo100() {
+ UserRoleService service = service(emptyAllocationsProps());
+
+ assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(100);
+ assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(100);
+ assertThat(service.getCreditAllocationForRole("ROLE_UNKNOWN")).isEqualTo(100);
+ }
+
+ @Test
+ @DisplayName("null role id is treated as non-ROLE_USER and falls back to 100")
+ void nullRoleIdFallsBackTo100() {
+ UserRoleService service = service(emptyAllocationsProps());
+
+ // getOrDefault(null, ...) misses (no null key); the ternary's equals(null) is false
+ // -> 100. Must not NPE because Role.USER.getRoleId().equals(roleId) is null-safe.
+ assertThat(service.getCreditAllocationForRole(null)).isEqualTo(100);
+ }
+
+ @Test
+ @DisplayName("a configured value of zero is returned verbatim, not replaced by a fallback")
+ void zeroAllocationReturnedVerbatim() {
+ UserRoleService service = service(propsWith(Map.of("ROLE_WEB_ONLY_USER", 0)));
+
+ assertThat(service.getCreditAllocationForRole("ROLE_WEB_ONLY_USER")).isZero();
+ }
+
+ @Test
+ @DisplayName("getCreditAllocationForRole never touches the persistence collaborators")
+ void doesNotTouchRepositories() {
+ UserRoleService service = service(defaultProps());
+
+ service.getCreditAllocationForRole(ROLE_USER);
+
+ Mockito.verifyNoInteractions(userRepository, authorityRepository, creditService);
+ }
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java b/app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java
new file mode 100644
index 000000000..aff58fdf0
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java
@@ -0,0 +1,348 @@
+package stirling.software.saas.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.model.Authority;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamCredit;
+import stirling.software.saas.model.UserCredit;
+import stirling.software.saas.service.CreditService;
+import stirling.software.saas.service.SaasTeamExtensionService;
+import stirling.software.saas.service.TeamCreditService;
+
+/**
+ * Unit tests for {@link CreditHeaderUtils#getRemainingCredits(User, CreditService,
+ * TeamCreditService)}.
+ *
+ * The method resolves a remaining-credit balance by routing between two pools:
+ *
+ *
+ * Team pool - used only when the user is NOT a limited-API user, has a team, and that
+ * team is not "personal" per {@link SaasTeamExtensionService#isPersonal}.
+ * Personal pool - otherwise; looked up by Supabase id first, then API key, else -1.
+ *
+ *
+ * Any thrown exception is swallowed and yields the sentinel {@code -1}. All collaborators are
+ * mocked; the assertions are pure arithmetic on the resolved balance.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CreditHeaderUtilsTest {
+
+ @Mock private SaasTeamExtensionService saasTeamExtensionService;
+ @Mock private CreditService creditService;
+ @Mock private TeamCreditService teamCreditService;
+
+ @InjectMocks private CreditHeaderUtils creditHeaderUtils;
+
+ private static final long TEAM_ID = 77L;
+ private static final UUID SUPABASE_ID = UUID.fromString("00000000-0000-0000-0000-000000000123");
+ private static final String API_KEY = "api-key-abcdef";
+
+ // --- builders -------------------------------------------------------------------------------
+
+ private static User user() {
+ User u = new User();
+ u.setUsername("tester");
+ return u;
+ }
+
+ private static Team team(Long id) {
+ Team t = new Team();
+ t.setId(id);
+ t.setName("team-" + id);
+ return t;
+ }
+
+ /** Authority ctor self-registers on the user's authority set. */
+ private static void grant(User u, String role) {
+ new Authority(role, u);
+ }
+
+ private static UserCredit userCredit(int cycle, int bought) {
+ UserCredit c = new UserCredit(user());
+ c.setCycleCreditsRemaining(cycle);
+ c.setBoughtCreditsRemaining(bought);
+ return c;
+ }
+
+ private static TeamCredit teamCredit(int cycle, int bought) {
+ TeamCredit c = new TeamCredit(team(TEAM_ID));
+ c.setCycleCreditsRemaining(cycle);
+ c.setBoughtCreditsRemaining(bought);
+ return c;
+ }
+
+ private int call(User u) {
+ return creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService);
+ }
+
+ // --- team pool routing ----------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("team pool path (non-limited user on a non-personal team)")
+ class TeamPool {
+
+ @Test
+ @DisplayName(
+ "returns the team's total available credits and never touches personal lookups")
+ void nonPersonalTeam_usesTeamPool() {
+ User u = user();
+ u.setTeam(team(TEAM_ID));
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.getTeamCredits(TEAM_ID))
+ .thenReturn(Optional.of(teamCredit(40, 10)));
+
+ assertThat(call(u)).isEqualTo(50);
+
+ verify(teamCreditService).getTeamCredits(TEAM_ID);
+ verifyNoInteractions(creditService);
+ }
+
+ @Test
+ @DisplayName("missing team credit row yields the -1 sentinel")
+ void nonPersonalTeam_missingRow_returnsMinusOne() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ u.setTeam(team(TEAM_ID));
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.getTeamCredits(TEAM_ID)).thenReturn(Optional.empty());
+
+ assertThat(call(u)).isEqualTo(-1);
+
+ // Team path chosen, so the personal supabase lookup must never run.
+ verifyNoInteractions(creditService);
+ }
+
+ @Test
+ @DisplayName("zero team credits returns 0, not the sentinel")
+ void nonPersonalTeam_zeroBalance_returnsZero() {
+ User u = user();
+ u.setTeam(team(TEAM_ID));
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.getTeamCredits(TEAM_ID))
+ .thenReturn(Optional.of(teamCredit(0, 0)));
+
+ assertThat(call(u)).isZero();
+ }
+ }
+
+ // --- personal pool routing ------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("personal pool path")
+ class PersonalPool {
+
+ @Test
+ @DisplayName("personal team falls through to the user's individual credits")
+ void personalTeam_usesPersonalCredits() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ u.setTeam(team(TEAM_ID));
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
+ when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
+ .thenReturn(Optional.of(userCredit(15, 5)));
+
+ assertThat(call(u)).isEqualTo(20);
+
+ // Personal path chosen -> team pool untouched.
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName(
+ "no team at all uses personal credits and never consults the extension service")
+ void noTeam_usesPersonalCredits() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
+ .thenReturn(Optional.of(userCredit(7, 0)));
+
+ assertThat(call(u)).isEqualTo(7);
+
+ verifyNoInteractions(saasTeamExtensionService);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName("supabase id is preferred over api key when both are present")
+ void supabaseIdPreferredOverApiKey() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ u.setApiKey(API_KEY);
+ when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
+ .thenReturn(Optional.of(userCredit(3, 4)));
+
+ assertThat(call(u)).isEqualTo(7);
+
+ verify(creditService).getUserCreditsBySupabaseId(SUPABASE_ID.toString());
+ verify(creditService, never()).getUserCreditsByApiKey(anyString());
+ }
+
+ @Test
+ @DisplayName("falls back to api key lookup when supabase id is absent")
+ void apiKeyFallback_whenNoSupabaseId() {
+ User u = user();
+ u.setApiKey(API_KEY);
+ when(creditService.getUserCreditsByApiKey(API_KEY))
+ .thenReturn(Optional.of(userCredit(9, 1)));
+
+ assertThat(call(u)).isEqualTo(10);
+
+ verify(creditService).getUserCreditsByApiKey(API_KEY);
+ verify(creditService, never()).getUserCreditsBySupabaseId(anyString());
+ }
+
+ @Test
+ @DisplayName("supabase lookup returning empty yields -1")
+ void supabaseLookupEmpty_returnsMinusOne() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
+ .thenReturn(Optional.empty());
+
+ assertThat(call(u)).isEqualTo(-1);
+ }
+
+ @Test
+ @DisplayName("api key lookup returning empty yields -1")
+ void apiKeyLookupEmpty_returnsMinusOne() {
+ User u = user();
+ u.setApiKey(API_KEY);
+ when(creditService.getUserCreditsByApiKey(API_KEY)).thenReturn(Optional.empty());
+
+ assertThat(call(u)).isEqualTo(-1);
+ }
+
+ @Test
+ @DisplayName("neither supabase id nor api key present yields -1 with no lookups")
+ void noIdentifiers_returnsMinusOne() {
+ User u = user(); // no supabaseId, no apiKey, no team
+
+ assertThat(call(u)).isEqualTo(-1);
+
+ verifyNoInteractions(creditService);
+ verifyNoInteractions(teamCreditService);
+ }
+ }
+
+ // --- limited-api user override --------------------------------------------------------------
+
+ @Nested
+ @DisplayName("limited-api users always read personal credits")
+ class LimitedApiOverride {
+
+ @Test
+ @DisplayName("ROLE_LIMITED_API_USER on a non-personal team still reads personal credits")
+ void limitedApiUser_skipsTeamPool() {
+ User u = user();
+ u.setApiKey(API_KEY);
+ u.setTeam(team(TEAM_ID));
+ grant(u, "ROLE_LIMITED_API_USER");
+ when(creditService.getUserCreditsByApiKey(API_KEY))
+ .thenReturn(Optional.of(userCredit(2, 0)));
+
+ assertThat(call(u)).isEqualTo(2);
+
+ // The team branch is gated out, so neither the extension nor team-credit services run.
+ verifyNoInteractions(saasTeamExtensionService);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName("ROLE_EXTRA_LIMITED_API_USER also forces the personal pool")
+ void extraLimitedApiUser_skipsTeamPool() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ u.setTeam(team(TEAM_ID));
+ grant(u, "ROLE_EXTRA_LIMITED_API_USER");
+ when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
+ .thenReturn(Optional.of(userCredit(6, 0)));
+
+ assertThat(call(u)).isEqualTo(6);
+
+ verifyNoInteractions(saasTeamExtensionService);
+ verify(teamCreditService, never()).getTeamCredits(any());
+ }
+
+ @Test
+ @DisplayName(
+ "a non-limited role does not divert a non-personal team member off the team pool")
+ void unrelatedRole_keepsTeamPool() {
+ User u = user();
+ u.setTeam(team(TEAM_ID));
+ grant(u, "ROLE_USER");
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.getTeamCredits(TEAM_ID))
+ .thenReturn(Optional.of(teamCredit(11, 0)));
+
+ assertThat(call(u)).isEqualTo(11);
+
+ verify(teamCreditService).getTeamCredits(TEAM_ID);
+ verifyNoInteractions(creditService);
+ }
+ }
+
+ // --- error swallowing -----------------------------------------------------------------------
+
+ @Nested
+ @DisplayName("exceptions are swallowed and produce the -1 sentinel")
+ class ErrorHandling {
+
+ @Test
+ @DisplayName("team credit service throwing is caught and returns -1")
+ void teamServiceThrows_returnsMinusOne() {
+ User u = user();
+ u.setTeam(team(TEAM_ID));
+ when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
+ when(teamCreditService.getTeamCredits(TEAM_ID))
+ .thenThrow(new RuntimeException("db down"));
+
+ assertThat(call(u)).isEqualTo(-1);
+ }
+
+ @Test
+ @DisplayName("credit service throwing on personal lookup is caught and returns -1")
+ void creditServiceThrows_returnsMinusOne() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ when(creditService.getUserCreditsBySupabaseId(anyString()))
+ .thenThrow(new RuntimeException("lookup boom"));
+
+ assertThat(call(u)).isEqualTo(-1);
+ }
+
+ @Test
+ @DisplayName(
+ "extension service throwing during personal-team check is caught and returns -1")
+ void extensionServiceThrows_returnsMinusOne() {
+ User u = user();
+ u.setSupabaseId(SUPABASE_ID);
+ u.setTeam(team(TEAM_ID));
+ when(saasTeamExtensionService.isPersonal(u.getTeam()))
+ .thenThrow(new RuntimeException("extension boom"));
+
+ assertThat(call(u)).isEqualTo(-1);
+ }
+ }
+}