Add JUnit tests for saas module coverage (#6699)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
This commit is contained in:
Anthony Stirling
2026-06-17 11:04:53 +00:00
committed by GitHub
parent 460c037bbb
commit de9242c4f7
22 changed files with 13831 additions and 0 deletions
@@ -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<AiCreateController.AiCreateSessionResponse> 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<AiCreateController.AiCreateSessionResponse> 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<AiCreateController.AiCreateSessionResponse> resp =
controller.updateSession("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
ArgumentCaptor<String> constraints = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> 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<AiCreateController.AiCreateSessionResponse> 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<String> constraints = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> 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");
}
}
}
@@ -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}.
*
* <p>All endpoints funnel through one private {@code proxy(method, path, request,
* acceptEventStream, includeCreditsHeader)} helper, so the suite has two halves:
*
* <ol>
* <li>per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a
* given handler forwards (the path-mapping surface), and
* <li>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}.
* </ol>
*
* <p>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<StreamingResponseBody> 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<StreamingResponseBody> 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<InputStream> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<InputStream> upstream =
upstreamResponse(404, "nope", httpHeaders(Map.of()));
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> 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<InputStream> upstream =
upstreamResponse(299, "weird", httpHeaders(Map.of()));
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> 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<InputStream> 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<StreamingResponseBody> 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<InputStream> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<InputStream> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<StreamingResponseBody> 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<InputStream> response)
throws Exception {
when(aiProxyService.forward(eq(method), eq(path), eq(req), eq(acceptEventStream)))
.thenReturn(response);
}
private static HttpResponse<InputStream> 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<String, String> single) {
Map<String, List<String>> 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<InputStream> upstreamResponse(
int status, String body, java.net.http.HttpHeaders headers) {
HttpResponse<InputStream> 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);
}
}
@@ -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}.
*
* <p>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.
*
* <p>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<InputStream> response =
(HttpResponse<InputStream>) 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<HttpRequest> 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<InputStream> 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<Throwable> error =
new java.util.concurrent.atomic.AtomicReference<>();
java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer> 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
}
};
}
}
@@ -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}.
*
* <p>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<String, Object> headers = new HashMap<>();
headers.put("alg", "RS256");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", supabaseId);
claims.put("email", "[email protected]");
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
EnhancedJwtAuthenticationToken auth =
new EnhancedJwtAuthenticationToken(
jwt,
List.of(new SimpleGrantedAuthority("ROLE_USER")),
"[email protected]",
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:<id>" 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("[email protected]");
assertThat(serviceWithUserService().resolveUserId()).isEqualTo("[email protected]");
}
@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<AiCreateSession> 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<AiCreateSession> 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<AiCreateSession> 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<AiCreateSession> 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<AiCreateSessionRepository.AiCreateSessionSummaryProjection> 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<AiCreateSessionRepository.AiCreateSessionSummaryProjection> 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<AiCreateSession> expected = List.of();
when(repository.findByUserIdOrderByUpdatedAtDesc(DEFAULT_USER_ID)).thenReturn(expected);
assertThat(serviceWithoutUserService().listSessionsForCurrentUser()).isSameAs(expected);
ArgumentCaptor<String> userIdCaptor = ArgumentCaptor.forClass(String.class);
verify(repository).findByUserIdOrderByUpdatedAtDesc(userIdCaptor.capture());
assertThat(userIdCaptor.getValue()).isEqualTo(DEFAULT_USER_ID);
}
}
}
@@ -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}.
*
* <p>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.
*
* <p>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<InputStream> response =
(HttpResponse<InputStream>) 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<HttpRequest> 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<InputStream> 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<java.nio.ByteBuffer> 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
}
};
}
}
@@ -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}.
*
* <p>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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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("[email protected]");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(42L);
upgraded.setEmail("[email protected]");
upgraded.setUsername("[email protected]");
when(saasUserAccountService.synchronizeUserUpgrade(
supabaseUser, "[email protected]", "google"))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> 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", "[email protected]");
}
@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("[email protected]");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(7L);
upgraded.setEmail("[email protected]");
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser(" GitHub ", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<String> methodCaptor = ArgumentCaptor.forClass(String.class);
verify(saasUserAccountService)
.synchronizeUserUpgrade(
eq(supabaseUser), eq("[email protected]"), 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("[email protected]");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(7L);
upgraded.setEmail("[email protected]");
when(saasUserAccountService.synchronizeUserUpgrade(
eq(supabaseUser), eq("[email protected]"), eq(null)))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser(null, principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(saasUserAccountService).synchronizeUserUpgrade(supabaseUser, "[email protected]", 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("[email protected]");
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<Map<String, String>> 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<Map<String, String>> 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<Map<String, String>> 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<Map<String, String>> 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("[email protected]"));
ResponseEntity<Map<String, String>> 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("[email protected]");
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("[email protected]");
when(saasUserAccountService.synchronizeUserUpgrade(
eq(supabaseUser), eq("[email protected]"), anyString()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(saasUserAccountService)
.synchronizeUserUpgrade(supabaseUser, "[email protected]", "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<Map<String, String>> 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("[email protected]");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
.thenThrow(new RuntimeException("db down"));
ResponseEntity<Map<String, String>> 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<Map<String, String>> 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("[email protected]");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(1L);
upgraded.setEmail("[email protected]");
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser(method, principal(USERNAME));
assertThat(response.getStatusCode())
.as("method %s should be accepted", method)
.isEqualTo(HttpStatus.OK);
}
}
}
}
@@ -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}.
*
* <p>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.
*
* <p>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<String, Object> bodyOf(ResponseEntity<Object> resp) {
return (Map<String, Object>) 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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<Object> 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);
}
}
@@ -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}.
*
* <p>The advice is a {@code @RestControllerAdvice} {@code ResponseBodyAdvice} that, on a successful
* (status &lt; 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.
*
* <p>{@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.
*
* <p>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();
}
}
@@ -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() {}
}
}
@@ -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}.
*
* <p>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.
*
* <p>Important: the streams are consumed inside try-with-resources, so the SupabaseUser stream is a
* {@code Stream<UUID>} and the User stream is a {@code Stream<Long>}; 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<UUID> uuids(int n) {
List<UUID> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
out.add(UUID.randomUUID());
}
return out;
}
private static List<Long> longs(int n) {
List<Long> 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<LocalDateTime> supaCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
verify(supabaseUserRepository)
.findByCreatedAtBeforeAndIsAnonymousTrue(supaCutoff.capture());
assertThat(supaCutoff.getValue()).isBetween(expectedLower, expectedUpper);
ArgumentCaptor<LocalDateTime> 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<LocalDateTime> supaCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
verify(supabaseUserRepository)
.findByCreatedAtBeforeAndIsAnonymousTrue(supaCutoff.capture());
ArgumentCaptor<LocalDateTime> 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<UUID> supaIds = uuids(3);
List<Long> 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<UUID> 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<UUID> 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<List<UUID>> captor = ArgumentCaptor.forClass(List.class);
verify(supabaseUserRepository, times(3)).deleteAllByIdInBatch(captor.capture());
List<List<UUID>> 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<Long> 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<List<Long>> captor = ArgumentCaptor.forClass(List.class);
verify(userRepository, times(2)).deleteAllByIdInBatch(captor.capture());
List<List<Long>> 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<Long> 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<List<Long>> 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<UUID> supaIds = uuids(3); // [2,1]
List<Long> 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<UUID> supaStream = uuids(2).stream().onClose(() -> supaClosed[0] = true);
Stream<Long> 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();
}
}
}
@@ -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}.
*
* <p>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<LocalDateTime> userTime = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> 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<LocalDateTime> 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<LocalDateTime> 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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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}.
*
* <p>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<UserErrorTracker> 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<UserErrorTracker> 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());
}
}
@@ -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}.
*
* <p>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);
}
}
@@ -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}.
*
* <p>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<SaasTeamExtensions> 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<SaasTeamExtensions> 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<SaasTeamExtensions> 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<SaasTeamExtensions> captor =
ArgumentCaptor.forClass(SaasTeamExtensions.class);
verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
assertThat(captor.getValue().getCreatedByUserId()).isEqualTo(13L);
}
}
}
@@ -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}.
*
* <p>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("[email protected]");
// 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, "[email protected]", "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, "[email protected]", "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, "[email protected]", "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, "[email protected]", "web");
assertThat(result).isSameAs(u);
// setAuthenticationType stores the enum name lowercased.
assertThat(u.getAuthenticationType()).isEqualTo("web");
assertThat(u.getEmail()).isEqualTo("[email protected]");
assertThat(u.getUsername()).isEqualTo("[email protected]");
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, "[email protected]", "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, "[email protected]", "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, "[email protected]", "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, "[email protected]", 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("[email protected]");
u.setAuthenticationType(AuthenticationType.WEB);
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
User result = service.synchronizeUserUpgrade(su, "[email protected]", "google");
assertThat(result).isSameAs(u);
assertThat(u.getUsername()).isEqualTo("[email protected]");
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, "[email protected]", "web");
verify(userService).saveUser(u);
}
}
}
@@ -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}.
*
* <p>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 = "[email protected]";
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, "[email protected]", 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<SupabaseUser> 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<SupabaseUser> 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<SupabaseUser> 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");
}
}
@@ -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<TeamCredit> 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<TeamCredit> 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<TeamCredit> 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<String> 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());
}
}
}
@@ -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}.
*
* <p>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.
*
* <p>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<String, Integer> 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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);
}
}
}
@@ -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)}.
*
* <p>The method resolves a remaining-credit balance by routing between two pools:
*
* <ul>
* <li><b>Team pool</b> - used only when the user is NOT a limited-API user, has a team, and that
* team is not "personal" per {@link SaasTeamExtensionService#isPersonal}.
* <li><b>Personal pool</b> - otherwise; looked up by Supabase id first, then API key, else -1.
* </ul>
*
* 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);
}
}
}