mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
+1035
File diff suppressed because it is too large
Load Diff
+457
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+802
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+676
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+783
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1355
File diff suppressed because it is too large
Load Diff
+556
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+679
@@ -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 < 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+806
@@ -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() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+351
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+585
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+475
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user