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: + * + *

    + *
  1. per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a + * given handler forwards (the path-mapping surface), and + *
  2. 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); + } + } +}