Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282f18705d |
@@ -262,12 +262,6 @@ tasks:
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
@@ -299,7 +293,6 @@ tasks:
|
||||
- task: typecheck:proprietary
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:cloud
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
|
||||
@@ -169,29 +169,7 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
|
||||
|
||||
#### Frontend `cloud/` Layer
|
||||
|
||||
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
|
||||
|
||||
- **core** → core
|
||||
- **proprietary** → proprietary → core
|
||||
- **saas** → saas → cloud → proprietary → core
|
||||
- **desktop** → desktop → cloud → proprietary → core
|
||||
- **cloud** → cloud → proprietary → core
|
||||
|
||||
What goes where:
|
||||
|
||||
- **core** — OSS base.
|
||||
- **proprietary** — licensed / offline features.
|
||||
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
|
||||
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
|
||||
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
|
||||
|
||||
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
|
||||
|
||||
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
@@ -16,8 +16,6 @@ if that directory exists, is licensed under the license defined in "frontend/edi
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/portal/" directory of this repository,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repositories {
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
}
|
||||
|
||||
ext {
|
||||
|
||||
+5
-35
@@ -1,10 +1,6 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -65,43 +61,17 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
|
||||
PersistentAuditEvent ent =
|
||||
PersistentAuditEvent.builder()
|
||||
.principal(safePrincipal(ev.getPrincipal()))
|
||||
.principal(ev.getPrincipal())
|
||||
.type(ev.getType())
|
||||
.data(auditEventData)
|
||||
.timestamp(ev.getTimestamp())
|
||||
.build();
|
||||
repo.save(ent);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to persist audit event (fail-open); type={}", ev.getType(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Width of the {@code principal} column; longer values are hashed so the insert can't fail. */
|
||||
private static final int PRINCIPAL_MAX_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Hash JWT-shaped or over-long principals so the insert fits the column and stores no secret.
|
||||
*/
|
||||
static String safePrincipal(String principal) {
|
||||
if (principal == null || principal.isBlank()) {
|
||||
return "anonymous";
|
||||
}
|
||||
// Hash JWTs ("eyJ...") and any over-long value rather than store verbatim.
|
||||
if (principal.startsWith("eyJ") || principal.length() > PRINCIPAL_MAX_LENGTH) {
|
||||
return "token:" + sha256Prefix(principal);
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
/** First 8 bytes of SHA-256 as hex: stable, one-way, collision-safe enough. */
|
||||
private static String sha256Prefix(String value) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 8);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
return "unhashable";
|
||||
log.error(
|
||||
"Failed to persist audit event (fail-open); principal={}",
|
||||
ev.getPrincipal(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -43,8 +43,8 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"MCP audience binding is not configured; rejecting all tokens until"
|
||||
+ " mcp.auth.resource-id or mcp.auth.accepted-audiences is set.",
|
||||
"MCP server has no resource id configured; rejecting all tokens"
|
||||
+ " until mcp.auth.resource-id is set.",
|
||||
null));
|
||||
}
|
||||
List<String> aud = token.getAudience();
|
||||
|
||||
+5
-43
@@ -4,21 +4,15 @@ import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728) from
|
||||
* X-Forwarded-* headers. A rejected token also logs the OAuth2 reason and echoes it as {@code
|
||||
* error_description}.
|
||||
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
|
||||
* X-Forwarded-* headers to build the public-facing metadata URL.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final String metadataPath;
|
||||
@@ -34,47 +28,15 @@ public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
// Tokenless 401 is the normal discovery handshake; only a rejected token is a real failure.
|
||||
boolean tokenPresented = request.getHeader("Authorization") != null;
|
||||
String reason = rejectionReason(authException);
|
||||
if (tokenPresented) {
|
||||
log.warn("MCP rejected bearer token: {}", reason != null ? reason : "invalid_token");
|
||||
} else {
|
||||
log.debug("MCP 401: no bearer token; returning protected-resource metadata pointer");
|
||||
}
|
||||
|
||||
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
|
||||
String authority = forwardedHost(request, scheme);
|
||||
String metadataUrl = scheme + "://" + authority + metadataPath;
|
||||
|
||||
StringBuilder header = new StringBuilder("Bearer error=\"invalid_token\"");
|
||||
if (tokenPresented && reason != null) {
|
||||
header.append(", error_description=\"").append(reason).append('"');
|
||||
}
|
||||
header.append(", resource_metadata=\"").append(metadataUrl).append('"');
|
||||
response.setHeader("WWW-Authenticate", header.toString());
|
||||
response.setHeader(
|
||||
"WWW-Authenticate",
|
||||
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
|
||||
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 error as {@code "code - description"}, sanitized for a header/log line; null if none.
|
||||
*/
|
||||
private static String rejectionReason(AuthenticationException ex) {
|
||||
if (!(ex instanceof OAuth2AuthenticationException oae)) {
|
||||
return null;
|
||||
}
|
||||
OAuth2Error error = oae.getError();
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
String description = error.getDescription();
|
||||
String combined =
|
||||
(description == null || description.isBlank())
|
||||
? error.getErrorCode()
|
||||
: error.getErrorCode() + " - " + description;
|
||||
return combined == null ? null : combined.replaceAll("[\\r\\n\"]", " ").trim();
|
||||
}
|
||||
|
||||
/** host[:port] from forwarded headers when present, else the servlet host/port. */
|
||||
private static String forwardedHost(HttpServletRequest request, String scheme) {
|
||||
String host = firstForwarded(request, "X-Forwarded-Host", null);
|
||||
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Startup sanity-checks for MCP config; {@link McpSecurityConfig} logs the findings at boot so a
|
||||
* misconfigured /mcp endpoint shows up in the logs instead of as a later rejected-token 401.
|
||||
*/
|
||||
public final class McpConfigValidator {
|
||||
|
||||
public enum Severity {
|
||||
WARN,
|
||||
INFO
|
||||
}
|
||||
|
||||
public record Finding(Severity severity, String message) {}
|
||||
|
||||
private McpConfigValidator() {}
|
||||
|
||||
/** Inspect the resolved MCP config and return ordered findings (most actionable first). */
|
||||
public static List<Finding> validate(ApplicationProperties.Mcp mcp) {
|
||||
List<Finding> findings = new ArrayList<>();
|
||||
ApplicationProperties.Mcp.Auth auth = mcp.getAuth();
|
||||
|
||||
if ("apikey".equalsIgnoreCase(auth.getMode())) {
|
||||
findings.add(
|
||||
info(
|
||||
"auth mode = apikey - clients send a Stirling API key via X-API-KEY (or"
|
||||
+ " Authorization: Bearer <key>); no external IdP needed. The key"
|
||||
+ " must belong to a provisioned, enabled account (Account -> API"
|
||||
+ " Keys)."));
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Anything that isn't exactly "apikey" runs the OAuth chain (mirrors isApiKeyMode()).
|
||||
String mode = auth.getMode();
|
||||
if (mode != null && !mode.isBlank() && !"oauth".equalsIgnoreCase(mode.trim())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.mode='"
|
||||
+ mode
|
||||
+ "' is not recognized (expected 'oauth' or 'apikey'); it falls"
|
||||
+ " back to the OAuth chain, which rejects every token unless"
|
||||
+ " issuer-uri and resource-id are set. A near-miss like"
|
||||
+ " 'api-key' is NOT treated as API-key mode."));
|
||||
}
|
||||
findings.add(info("auth mode = oauth - running as an OAuth2 resource server for /mcp."));
|
||||
|
||||
if (isBlank(auth.getIssuerUri())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.issuer-uri is not set: the JWT decoder fails closed and rejects"
|
||||
+ " every token. Set it to your IdP issuer that publishes"
|
||||
+ " /.well-known/openid-configuration (e.g."
|
||||
+ " https://login.microsoftonline.com/<tenant>/v2.0)."));
|
||||
} else if (!looksLikeUrl(auth.getIssuerUri())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.issuer-uri='"
|
||||
+ auth.getIssuerUri()
|
||||
+ "' does not look like an http(s) URL."));
|
||||
}
|
||||
|
||||
boolean hasResourceId = !isBlank(auth.getResourceId());
|
||||
boolean hasAcceptedAudiences =
|
||||
auth.getAcceptedAudiences().stream().anyMatch(a -> !isBlank(a));
|
||||
|
||||
if (!hasResourceId && !hasAcceptedAudiences) {
|
||||
findings.add(
|
||||
warn(
|
||||
"neither mcp.auth.resource-id nor mcp.auth.accepted-audiences is set: the"
|
||||
+ " audience validator fails closed and rejects every token (RFC"
|
||||
+ " 8707). Set resource-id to this server's public /mcp URL, or"
|
||||
+ " accepted-audiences to the audience your IdP actually mints."));
|
||||
} else {
|
||||
if (hasResourceId && !looksLikeUrl(auth.getResourceId())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.resource-id='"
|
||||
+ auth.getResourceId()
|
||||
+ "' is not an http(s) URL: the token aud must match it"
|
||||
+ " exactly (scheme, host and port included)."));
|
||||
} else if (hasResourceId && !auth.getResourceId().endsWith("/mcp")) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.resource-id='"
|
||||
+ auth.getResourceId()
|
||||
+ "' does not end in /mcp: it must match the public URL"
|
||||
+ " clients call and the audience your IdP puts in the"
|
||||
+ " token."));
|
||||
}
|
||||
if (hasAcceptedAudiences) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.auth.accepted-audiences="
|
||||
+ auth.getAcceptedAudiences()
|
||||
+ " - tokens whose aud matches any of these are accepted, the"
|
||||
+ " escape hatch for IdPs that can't mint a resource-specific"
|
||||
+ " audience (e.g. an Entra ID app id, or Supabase's"
|
||||
+ " aud=authenticated)."));
|
||||
} else {
|
||||
findings.add(
|
||||
info(
|
||||
"audience binding is strict (token aud must equal"
|
||||
+ " mcp.auth.resource-id). If your IdP can't mint that - e.g."
|
||||
+ " Entra ID issues aud=<client-id> - set"
|
||||
+ " mcp.auth.accepted-audiences to the audience it actually"
|
||||
+ " emits."));
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlank(auth.getJwksUri())) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.auth.jwks-uri not set - signing keys are auto-discovered from the"
|
||||
+ " issuer's OpenID configuration."));
|
||||
}
|
||||
|
||||
if ("sub".equalsIgnoreCase(auth.getUsernameClaim()) && auth.isRequireExistingAccount()) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.username-claim='sub' with require-existing-account=true: many"
|
||||
+ " IdPs (e.g. Entra ID, Google) set 'sub' to an opaque id that won't"
|
||||
+ " match a Stirling username. Set mcp.auth.username-claim to 'email'"
|
||||
+ " or 'preferred_username', or provision accounts keyed by sub."));
|
||||
}
|
||||
|
||||
if (!auth.isRequireExistingAccount()) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.require-existing-account=false: any token your IdP signs can"
|
||||
+ " invoke MCP tools even if its subject has no Stirling account. Set"
|
||||
+ " it true unless you intend open access for every IdP-valid"
|
||||
+ " token."));
|
||||
}
|
||||
|
||||
if (mcp.isScopesEnabled()) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.scopes-enabled=true - the IdP must mint 'mcp.tools.read' and"
|
||||
+ " 'mcp.tools.write' scopes or clients are rejected; set"
|
||||
+ " mcp.scopes-enabled=false if it can only issue coarse tokens."));
|
||||
}
|
||||
|
||||
List<String> allowed = mcp.getAllowedOperations();
|
||||
List<String> blocked = mcp.getBlockedOperations();
|
||||
if (allowed != null && !allowed.isEmpty()) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.allowed-operations is a strict allow-list of "
|
||||
+ allowed.size()
|
||||
+ " operation(s); every other tool is hidden, so a wrong or"
|
||||
+ " typo'd id silently exposes nothing."));
|
||||
List<String> shadowed =
|
||||
blocked == null
|
||||
? List.of()
|
||||
: allowed.stream().filter(blocked::contains).toList();
|
||||
if (!shadowed.isEmpty()) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp operation(s) "
|
||||
+ shadowed
|
||||
+ " are in both allowed-operations and blocked-operations;"
|
||||
+ " blocked wins, so they are hidden."));
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.stream().noneMatch(f -> f.severity() == Severity.WARN)) {
|
||||
findings.add(info("OAuth settings look complete."));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static boolean looksLikeUrl(String value) {
|
||||
String lower = value.toLowerCase(Locale.ROOT);
|
||||
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||
}
|
||||
|
||||
private static Finding warn(String message) {
|
||||
return new Finding(Severity.WARN, message);
|
||||
}
|
||||
|
||||
private static Finding info(String message) {
|
||||
return new Finding(Severity.INFO, message);
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -77,14 +77,24 @@ public class McpSecurityConfig {
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void validateConfigOnStartup() {
|
||||
log.info("MCP server enabled - validating configuration:");
|
||||
for (McpConfigValidator.Finding finding :
|
||||
McpConfigValidator.validate(applicationProperties.getMcp())) {
|
||||
if (finding.severity() == McpConfigValidator.Severity.WARN) {
|
||||
log.warn("MCP config: {}", finding.message());
|
||||
} else {
|
||||
log.info("MCP config: {}", finding.message());
|
||||
void warnIfMisconfigured() {
|
||||
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
|
||||
if (isApiKeyMode()) {
|
||||
log.info(
|
||||
"MCP auth mode = apikey: clients authenticate with a Stirling per-user API key"
|
||||
+ " (X-API-KEY header). No OAuth issuer required.");
|
||||
} else {
|
||||
if (mcp.getAuth().getIssuerUri().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject"
|
||||
+ " every token (fail-closed). Set mcp.auth.issuer-uri and"
|
||||
+ " mcp.auth.resource-id before exposing /mcp to clients.");
|
||||
}
|
||||
if (mcp.getAuth().getResourceId().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.resource-id is blank - audience validator will"
|
||||
+ " reject every token. Set this to the public URL of the MCP"
|
||||
+ " endpoint (RFC 8707).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CustomAuditEventRepositoryTest {
|
||||
|
||||
@Test
|
||||
void shortPrincipalPassesThroughUnchanged() {
|
||||
assertEquals(
|
||||
"alice@example.com", CustomAuditEventRepository.safePrincipal("alice@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankOrNullPrincipalBecomesAnonymous() {
|
||||
assertEquals("anonymous", CustomAuditEventRepository.safePrincipal(null));
|
||||
assertEquals("anonymous", CustomAuditEventRepository.safePrincipal(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenShapedPrincipalIsHashedNotStoredVerbatim() {
|
||||
String jwt = "eyJhbGciOiJSUzI1NiJ9." + "x".repeat(1400);
|
||||
|
||||
String safe = CustomAuditEventRepository.safePrincipal(jwt);
|
||||
|
||||
assertNotEquals(jwt, safe);
|
||||
assertFalse(safe.contains(jwt), "raw token must not be stored");
|
||||
assertTrue(safe.startsWith("token:"));
|
||||
assertTrue(safe.length() <= 255, "must fit the principal column");
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctTokensStayDistinguishable() {
|
||||
String a = CustomAuditEventRepository.safePrincipal("eyJ" + "a".repeat(400));
|
||||
String b = CustomAuditEventRepository.safePrincipal("eyJ" + "b".repeat(400));
|
||||
|
||||
assertNotEquals(a, b, "different tokens must map to different audit principals");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameTokenHashesStably() {
|
||||
String token = "eyJ" + "c".repeat(400);
|
||||
|
||||
assertEquals(
|
||||
CustomAuditEventRepository.safePrincipal(token),
|
||||
CustomAuditEventRepository.safePrincipal(token));
|
||||
}
|
||||
}
|
||||
-28
@@ -11,8 +11,6 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -60,30 +58,4 @@ class McpAuthenticationEntryPointTest {
|
||||
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
|
||||
assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void surfacesRejectionReasonWhenTokenPresented() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getScheme()).thenReturn("https");
|
||||
when(req.getServerName()).thenReturn("mcp.example.com");
|
||||
when(req.getServerPort()).thenReturn(443);
|
||||
when(req.getHeader("Authorization")).thenReturn("Bearer bad.token");
|
||||
HttpServletResponse resp = mock(HttpServletResponse.class);
|
||||
|
||||
OAuth2Error error =
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Token audience does not include this server's resource id"
|
||||
+ " (https://mcp.example.com/mcp).",
|
||||
null);
|
||||
|
||||
entryPoint.commence(req, resp, new OAuth2AuthenticationException(error));
|
||||
|
||||
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
|
||||
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
|
||||
String www = header.getValue();
|
||||
assertTrue(
|
||||
www.contains("error_description=\"invalid_token - Token audience does not include"),
|
||||
"must surface the rejection reason, got: " + www);
|
||||
}
|
||||
}
|
||||
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class McpConfigValidatorTest {
|
||||
|
||||
private static ApplicationProperties.Mcp newMcp() {
|
||||
return new ApplicationProperties.Mcp();
|
||||
}
|
||||
|
||||
private static boolean hasWarn(List<McpConfigValidator.Finding> findings, String needle) {
|
||||
return findings.stream()
|
||||
.anyMatch(
|
||||
f ->
|
||||
f.severity() == McpConfigValidator.Severity.WARN
|
||||
&& f.message().contains(needle));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyModeSkipsOAuthChecks() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setMode("apikey");
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertEquals(1, findings.size());
|
||||
assertEquals(McpConfigValidator.Severity.INFO, findings.get(0).severity());
|
||||
assertTrue(findings.get(0).message().contains("apikey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankIssuerAndResourceProduceWarnings() {
|
||||
// Defaults: oauth mode, blank issuer-uri and resource-id.
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(newMcp());
|
||||
|
||||
assertTrue(hasWarn(findings, "issuer-uri"), "blank issuer must warn");
|
||||
assertTrue(hasWarn(findings, "resource-id"), "blank resource-id must warn");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subClaimWithRequireAccountWarns() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
// Defaults username-claim=sub, require-existing-account=true.
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "username-claim='sub'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeConfigReportsReadyWithNoWarnings() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.setScopesEnabled(false);
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertTrue(
|
||||
findings.stream().noneMatch(f -> f.severity() == McpConfigValidator.Severity.WARN),
|
||||
"complete config must have no warnings");
|
||||
assertTrue(findings.stream().anyMatch(f -> f.message().contains("look complete")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptedAudiencesCoverBlankResourceId() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("");
|
||||
mcp.getAuth().setAcceptedAudiences(List.of("authenticated"));
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.setScopesEnabled(false);
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertFalse(
|
||||
hasWarn(findings, "fails closed"),
|
||||
"accepted-audiences must satisfy audience binding without a resource id");
|
||||
assertTrue(
|
||||
findings.stream().anyMatch(f -> f.message().contains("accepted-audiences=")),
|
||||
"configured accepted-audiences should be surfaced");
|
||||
}
|
||||
|
||||
@Test
|
||||
void strictAudienceHintsAtAcceptedAudiencesEscapeHatch() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.setScopesEnabled(false);
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertTrue(
|
||||
findings.stream().anyMatch(f -> f.message().contains("accepted-audiences")),
|
||||
"should point coarse-audience IdPs at accepted-audiences");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrecognizedModeWarnsAboutOAuthFallback() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setMode("api-key"); // near-miss typo that silently runs the OAuth chain
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "is not recognized"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireExistingAccountFalseWarnsAboutOpenAccess() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.getAuth().setRequireExistingAccount(false);
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "require-existing-account=false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonUrlResourceIdWarns() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("localhost:8080/mcp"); // missing scheme
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "is not an http(s) URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowListIsFlaggedAndOverlapWithBlockListWarns() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.setAllowedOperations(List.of("merge-pdfs", "split-pdf"));
|
||||
mcp.setBlockedOperations(List.of("split-pdf"));
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertTrue(
|
||||
findings.stream().anyMatch(f -> f.message().contains("strict allow-list")),
|
||||
"an allow-list should be surfaced");
|
||||
assertTrue(hasWarn(findings, "blocked wins"), "allowed+blocked overlap should warn");
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ public class SupabaseSecurityConfig {
|
||||
applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
|
||||
List<String> configuredOrigins =
|
||||
List<String> origins =
|
||||
operatorOverride
|
||||
? applicationProperties.getSystem().getCorsAllowedOrigins()
|
||||
: List.of(
|
||||
@@ -270,18 +270,6 @@ public class SupabaseSecurityConfig {
|
||||
"https://stirling.com",
|
||||
"https://app.stirling.com",
|
||||
"https://api.stirling.com");
|
||||
// Always allow the desktop (Tauri) app's webview origins so the bundled
|
||||
// desktop client can reach the cloud backend regardless of the operator's
|
||||
// configured web origins. A browser can never present a tauri:// (or
|
||||
// tauri.localhost) origin, so these are desktop-app identities — safe to
|
||||
// allow alongside allowCredentials=true. Mirrors core WebMvcConfig.
|
||||
List<String> origins = new ArrayList<>(configuredOrigins);
|
||||
for (String desktopOrigin :
|
||||
List.of("tauri://localhost", "http://tauri.localhost", "https://tauri.localhost")) {
|
||||
if (!origins.contains(desktopOrigin)) {
|
||||
origins.add(desktopOrigin);
|
||||
}
|
||||
}
|
||||
if (origins.stream().anyMatch(o -> o.contains("*"))) {
|
||||
log.warn(
|
||||
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
|
||||
|
||||
-1035
File diff suppressed because it is too large
Load Diff
-457
@@ -1,457 +0,0 @@
|
||||
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
@@ -1,802 +0,0 @@
|
||||
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
@@ -1,676 +0,0 @@
|
||||
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
@@ -1,783 +0,0 @@
|
||||
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", "user@example.com");
|
||||
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
|
||||
EnhancedJwtAuthenticationToken auth =
|
||||
new EnhancedJwtAuthenticationToken(
|
||||
jwt,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
"user@example.com",
|
||||
supabaseId);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
|
||||
/** Bind a servlet request (optionally with a live HttpSession) to the current thread. */
|
||||
private static MockHttpServletRequest bindRequest(boolean withSession, String sessionId) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
if (withSession) {
|
||||
// (ServletContext, id) ctor fixes the session id so "session:<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("alice@corp.com");
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo("alice@corp.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank username is ignored and the chain falls through to the JWT id")
|
||||
void blankUsernameFallsThroughToJwt() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn(" ");
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("anonymousUser username is ignored and the chain falls through")
|
||||
void anonymousUsernameFallsThrough() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn("anonymousUser");
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null username is ignored and the chain falls through")
|
||||
void nullUsernameFallsThrough() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a throwing user service is swallowed and the chain falls through")
|
||||
void throwingUserServiceSwallowedAndFallsThrough() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absent user service bean skips tier 1 and uses the JWT id")
|
||||
void absentUserServiceUsesJwt() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unauthenticated 2-arg token (isAuthenticated=false) is skipped")
|
||||
void unauthenticatedTokenSkipped() {
|
||||
// The 2-arg UsernamePasswordAuthenticationToken ctor leaves isAuthenticated()=false,
|
||||
// so the JWT branch is bypassed and we fall through to the default.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new UsernamePasswordAuthenticationToken("bob", "creds"));
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("authenticated principal id is used via the generic getName() fallback")
|
||||
void authenticatedPrincipalNameUsed() {
|
||||
// A non-JWT authenticated token: extractSupabaseId falls back to getName().
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"carol",
|
||||
"creds",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))));
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("carol");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("authenticated 'anonymousUser' name is rejected, chain falls through")
|
||||
void anonymousAuthNameRejected() {
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"anonymousUser",
|
||||
"creds",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no auth + a live HttpSession yields a session-scoped id")
|
||||
void sessionScopedIdWhenNoAuth() {
|
||||
bindRequest(true, "sess-abc");
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("session:sess-abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no auth + a request without a session falls through to default")
|
||||
void noSessionFallsThroughToDefault() {
|
||||
bindRequest(false, null);
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no user service, no auth, no request context -> default_user")
|
||||
void defaultUserWhenNothingResolves() {
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JWT id is preferred over an available session-scoped id")
|
||||
void jwtPreferredOverSession() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
bindRequest(true, "sess-xyz");
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// createSession
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSession")
|
||||
class CreateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("populates every field, generates a session id, and persists once")
|
||||
void populatesAndSaves() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSessionService service = serviceWithUserService();
|
||||
|
||||
AiCreateSession out =
|
||||
service.createSession(
|
||||
"my prompt", "report", "tmpl-1", "\\documentclass{}", "preview");
|
||||
|
||||
assertThat(out.getUserId()).isEqualTo("owner");
|
||||
assertThat(out.getDocType()).isEqualTo("report");
|
||||
assertThat(out.getTemplateId()).isEqualTo("tmpl-1");
|
||||
assertThat(out.getTemplateTex()).isEqualTo("\\documentclass{}");
|
||||
assertThat(out.getPreviewTex()).isEqualTo("preview");
|
||||
assertThat(out.getPromptInitial()).isEqualTo("my prompt");
|
||||
assertThat(out.getPromptLatest()).isEqualTo("my prompt");
|
||||
assertThat(out.isOutlineApproved()).isFalse();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
// A random UUID session id was generated.
|
||||
assertThat(out.getSessionId()).isNotBlank();
|
||||
assertThat(UUID.fromString(out.getSessionId())).isNotNull();
|
||||
verify(repository).save(out);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two sessions get distinct generated ids")
|
||||
void distinctSessionIds() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSessionService service = serviceWithUserService();
|
||||
|
||||
AiCreateSession a = service.createSession("p", null, null, null, null);
|
||||
AiCreateSession b = service.createSession("p", null, null, null, null);
|
||||
|
||||
assertThat(a.getSessionId()).isNotEqualTo(b.getSessionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses default_user when nothing else resolves the identity")
|
||||
void usesDefaultUser() {
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService().createSession("p", "doc", "t", "tex", "prev");
|
||||
|
||||
assertThat(out.getUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// getSession / getSessionForCurrentUser
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSession / getSessionForCurrentUser")
|
||||
class GetSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession returns the persisted row")
|
||||
void getSessionReturnsRow() {
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(serviceWithoutUserService().getSession("s1")).isSameAs(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession throws 404 when the row is missing")
|
||||
void getSessionMissingThrows404() {
|
||||
when(repository.findById("nope")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> serviceWithoutUserService().getSession("nope"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
ex ->
|
||||
assertThat(((ResponseStatusException) ex).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSessionForCurrentUser returns the row when the owner matches")
|
||||
void ownerMatchReturnsRow() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(serviceWithUserService().getSessionForCurrentUser("s1")).isSameAs(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSessionForCurrentUser hides another user's session behind a 404")
|
||||
void foreignOwnerThrows404() {
|
||||
when(userService.getCurrentUsername()).thenReturn("intruder");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThatThrownBy(() -> serviceWithUserService().getSessionForCurrentUser("s1"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
ex ->
|
||||
assertThat(((ResponseStatusException) ex).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// updateOutline
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateOutline")
|
||||
class UpdateOutline {
|
||||
|
||||
@Test
|
||||
@DisplayName("sets outline text, filename, constraints, approval flag and APPROVED status")
|
||||
void fullUpdate() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithUserService()
|
||||
.updateOutline("s1", "the outline", "outline.tex", "be brief");
|
||||
|
||||
assertThat(out.getOutlineText()).isEqualTo("the outline");
|
||||
assertThat(out.getOutlineFilename()).isEqualTo("outline.tex");
|
||||
assertThat(out.getOutlineConstraints()).isEqualTo("be brief");
|
||||
assertThat(out.isOutlineApproved()).isTrue();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank filename is not applied; null constraints are left untouched")
|
||||
void blankFilenameAndNullConstraintsIgnored() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineFilename("keep.tex");
|
||||
row.setOutlineConstraints("keep-constraints");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateOutline("s1", "txt", " ", null);
|
||||
|
||||
assertThat(out.getOutlineFilename()).isEqualTo("keep.tex");
|
||||
assertThat(out.getOutlineConstraints()).isEqualTo("keep-constraints");
|
||||
// Still approved + status flipped even with skipped optional fields.
|
||||
assertThat(out.isOutlineApproved()).isTrue();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty-string constraints ARE applied (only null is skipped)")
|
||||
void emptyConstraintsApplied() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineConstraints("old");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateOutline("s1", "t", "f", "");
|
||||
|
||||
assertThat(out.getOutlineConstraints()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a foreign session 404s before any mutation or save")
|
||||
void foreignSessionBlocked() {
|
||||
when(userService.getCurrentUsername()).thenReturn("intruder");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThatThrownBy(() -> serviceWithUserService().updateOutline("s1", "t", "f", "c"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(row.getOutlineText()).isNull();
|
||||
verify(repository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// updateDraftSections
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("updateDraftSections stores sections and flips status to DRAFT_READY")
|
||||
void updateDraftSections() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateDraftSections("s1", "section json");
|
||||
|
||||
assertThat(out.getDraftSections()).isEqualTo("section json");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// updateTemplate
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateTemplate")
|
||||
class UpdateTemplate {
|
||||
|
||||
@Test
|
||||
@DisplayName("updates docType and templateId when both are non-blank")
|
||||
void updatesBoth() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setDocType("old-doc");
|
||||
row.setTemplateId("old-tmpl");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithUserService().updateTemplate("s1", "new-doc", "new-tmpl");
|
||||
|
||||
assertThat(out.getDocType()).isEqualTo("new-doc");
|
||||
assertThat(out.getTemplateId()).isEqualTo("new-tmpl");
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/blank inputs leave the existing template untouched")
|
||||
void blankInputsKeepExisting() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setDocType("old-doc");
|
||||
row.setTemplateId("old-tmpl");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateTemplate("s1", null, " ");
|
||||
|
||||
assertThat(out.getDocType()).isEqualTo("old-doc");
|
||||
assertThat(out.getTemplateId()).isEqualTo("old-tmpl");
|
||||
// Still persists (no-op save) — the method always saves.
|
||||
verify(repository).save(row);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// reprompt
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("reprompt resets all derived artifacts and re-enters OUTLINE_PENDING")
|
||||
void reprompt() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setPromptLatest("old prompt");
|
||||
row.setOutlineText("old outline");
|
||||
row.setOutlineFilename("old.tex");
|
||||
row.setOutlineApproved(true);
|
||||
row.setOutlineConstraints("old constraints");
|
||||
row.setDraftSections("old draft");
|
||||
row.setPolishedLatex("old latex");
|
||||
row.setPdfUrl("https://old/url");
|
||||
row.setStatus(AiCreateSessionStatus.POLISHED_READY);
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().reprompt("s1", "fresh prompt");
|
||||
|
||||
assertThat(out.getPromptLatest()).isEqualTo("fresh prompt");
|
||||
assertThat(out.getOutlineText()).isNull();
|
||||
assertThat(out.getOutlineFilename()).isNull();
|
||||
assertThat(out.isOutlineApproved()).isFalse();
|
||||
assertThat(out.getOutlineConstraints()).isNull();
|
||||
assertThat(out.getDraftSections()).isNull();
|
||||
assertThat(out.getPolishedLatex()).isNull();
|
||||
assertThat(out.getPdfUrl()).isNull();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// deleteSessionForCurrentUser
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteSessionForCurrentUser")
|
||||
class DeleteSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes the owner's session")
|
||||
void deletesOwnerSession() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
serviceWithUserService().deleteSessionForCurrentUser("s1");
|
||||
|
||||
verify(repository).delete(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a foreign session 404s and is never deleted")
|
||||
void foreignSessionNotDeleted() {
|
||||
when(userService.getCurrentUsername()).thenReturn("intruder");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThatThrownBy(() -> serviceWithUserService().deleteSessionForCurrentUser("s1"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(repository, never()).delete(any());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// applyInternalUpdate — null-coalescing partial update, no ownership check
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyInternalUpdate")
|
||||
class ApplyInternalUpdate {
|
||||
|
||||
@Test
|
||||
@DisplayName("applies every non-null field including outlineApproved=false")
|
||||
void appliesAllFields() {
|
||||
// No ownership check on the internal path: it uses getSession, not the per-user guard.
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineApproved(true);
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"s1",
|
||||
"outline",
|
||||
"o.tex",
|
||||
Boolean.FALSE,
|
||||
"constraints",
|
||||
"draft",
|
||||
"latex",
|
||||
"https://pdf/url",
|
||||
"doc",
|
||||
"tmpl",
|
||||
AiCreateSessionStatus.POLISHED_READY);
|
||||
|
||||
assertThat(out.getOutlineText()).isEqualTo("outline");
|
||||
assertThat(out.getOutlineFilename()).isEqualTo("o.tex");
|
||||
// Boolean.FALSE is non-null so it IS applied, flipping the prior true.
|
||||
assertThat(out.isOutlineApproved()).isFalse();
|
||||
assertThat(out.getOutlineConstraints()).isEqualTo("constraints");
|
||||
assertThat(out.getDraftSections()).isEqualTo("draft");
|
||||
assertThat(out.getPolishedLatex()).isEqualTo("latex");
|
||||
assertThat(out.getPdfUrl()).isEqualTo("https://pdf/url");
|
||||
assertThat(out.getDocType()).isEqualTo("doc");
|
||||
assertThat(out.getTemplateId()).isEqualTo("tmpl");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.POLISHED_READY);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all-null arguments leave the row untouched but still persist")
|
||||
void allNullLeavesUntouched() {
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineText("keep-outline");
|
||||
row.setDocType("keep-doc");
|
||||
row.setStatus(AiCreateSessionStatus.DRAFT_READY);
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"s1", null, null, null, null, null, null, null, null, null,
|
||||
null);
|
||||
|
||||
assertThat(out.getOutlineText()).isEqualTo("keep-outline");
|
||||
assertThat(out.getDocType()).isEqualTo("keep-doc");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("internal path 404s when the session does not exist")
|
||||
void missingSession404() {
|
||||
when(repository.findById("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"ghost", "x", null, null, null, null, null,
|
||||
null, null, null, null))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("internal path ignores ownership — updates a row owned by another user")
|
||||
void ignoresOwnership() {
|
||||
// applyInternalUpdate uses getSession (not getSessionForCurrentUser); current identity
|
||||
// is irrelevant. Confirm a non-matching identity still updates the row.
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
AiCreateSession row = existingSession("s1", "someone-else");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"s1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"https://done/pdf",
|
||||
null,
|
||||
null,
|
||||
AiCreateSessionStatus.SAVED);
|
||||
|
||||
assertThat(out.getPdfUrl()).isEqualTo("https://done/pdf");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.SAVED);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// list* — delegation to the right repository finder for the resolved user
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("listing methods")
|
||||
class Listing {
|
||||
|
||||
@Test
|
||||
@DisplayName("no-arg list delegates to findByUserIdOrderByUpdatedAtDesc(userId)")
|
||||
void listNoArg() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
List<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,678 +0,0 @@
|
||||
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
@@ -1,556 +0,0 @@
|
||||
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("new@stirling.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(42L);
|
||||
upgraded.setEmail("new@stirling.com");
|
||||
upgraded.setUsername("new@stirling.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(
|
||||
supabaseUser, "new@stirling.com", "google"))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<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", "new@stirling.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("normalizes auth method to lowercase/trimmed before delegating")
|
||||
void normalizesAuthMethod() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(7L);
|
||||
upgraded.setEmail("a@b.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<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("a@b.com"), methodCaptor.capture());
|
||||
assertThat(methodCaptor.getValue()).isEqualTo("github");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null authMethod is accepted and passed through as null")
|
||||
void nullAuthMethodAccepted() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(7L);
|
||||
upgraded.setEmail("a@b.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(
|
||||
eq(supabaseUser), eq("a@b.com"), eq(null)))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser(null, principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(saasUserAccountService).synchronizeUserUpgrade(supabaseUser, "a@b.com", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to username in body when upgraded user has no email")
|
||||
void emailFallsBackToUsername() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("canon@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(9L);
|
||||
upgraded.setEmail(null);
|
||||
upgraded.setUsername("fallback-username");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<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("x@y.com"));
|
||||
|
||||
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("local@stirling.com");
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail(" "); // blank
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(5L);
|
||||
upgraded.setEmail("local@stirling.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(
|
||||
eq(supabaseUser), eq("local@stirling.com"), anyString()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(saasUserAccountService)
|
||||
.synchronizeUserUpgrade(supabaseUser, "local@stirling.com", "email");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no email anywhere (Supabase and local both blank) returns 400")
|
||||
void noEmailAnywhere() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
current.setEmail(null);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail(null);
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
ResponseEntity<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("a@b.com");
|
||||
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("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
User upgraded = new User();
|
||||
upgraded.setId(1L);
|
||||
upgraded.setEmail("a@b.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser(method, principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode())
|
||||
.as("method %s should be accepted", method)
|
||||
.isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
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
@@ -1,679 +0,0 @@
|
||||
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
@@ -1,806 +0,0 @@
|
||||
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
@@ -1,351 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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
@@ -1,586 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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
@@ -1,585 +0,0 @@
|
||||
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
@@ -1,475 +0,0 @@
|
||||
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("alice@example.com");
|
||||
// Authority's constructor registers itself onto the user's authority set.
|
||||
new Authority(roleId, u);
|
||||
return u;
|
||||
}
|
||||
|
||||
private static Team team(Long id, String name) {
|
||||
Team t = new Team();
|
||||
t.setId(id);
|
||||
t.setName(name);
|
||||
return t;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getUserBySupabaseId")
|
||||
class GetUserBySupabaseId {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the local user when the UUID parses and a row exists")
|
||||
void returnsUser() {
|
||||
User u = userWithRole(Role.USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
assertThat(service.getUserBySupabaseId(SUPABASE_ID)).isSameAs(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws with an 'invalid format' message when the id is not a UUID")
|
||||
void invalidFormat_throws() {
|
||||
assertThatThrownBy(() -> service.getUserBySupabaseId("not-a-uuid"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid Supabase ID format")
|
||||
.hasMessageContaining("not-a-uuid");
|
||||
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws 'user not found' when the UUID parses but no local row exists")
|
||||
void notFound_throws() {
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.getUserBySupabaseId(SUPABASE_ID))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("User not found for Supabase ID")
|
||||
.hasMessageContaining(SUPABASE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handleUpgrade")
|
||||
class HandleUpgrade {
|
||||
|
||||
@Test
|
||||
@DisplayName("promotes a free (ROLE_USER) user to PRO and returns true")
|
||||
void freeUser_isUpgraded() {
|
||||
User u = userWithRole(Role.USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
boolean upgraded = service.handleUpgrade(SUPABASE_ID);
|
||||
|
||||
assertThat(upgraded).isTrue();
|
||||
verify(userRoleService).upgradeToPro(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false and does not re-upgrade a user already on PRO")
|
||||
void proUser_isNoOp() {
|
||||
User u = userWithRole(Role.PRO_USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
boolean upgraded = service.handleUpgrade(SUPABASE_ID);
|
||||
|
||||
assertThat(upgraded).isFalse();
|
||||
verify(userRoleService, never()).upgradeToPro(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false for any non-free role (e.g. admin/other) without upgrading")
|
||||
void otherRole_isNoOp() {
|
||||
User u = userWithRole("ROLE_ADMIN");
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
assertThat(service.handleUpgrade(SUPABASE_ID)).isFalse();
|
||||
verify(userRoleService, never()).upgradeToPro(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates the lookup failure when the supabase id has no local user")
|
||||
void unknownUser_propagates() {
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.handleUpgrade(SUPABASE_ID))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
verifyNoInteractions(userRoleService);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handleDowngrade")
|
||||
class HandleDowngrade {
|
||||
|
||||
@Test
|
||||
@DisplayName("downgrades a PRO user with no team to FREE and returns true")
|
||||
void proWithoutTeam_isDowngraded() {
|
||||
User u = userWithRole(Role.PRO_USER.getRoleId());
|
||||
// no team set -> getTeam() is null
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
boolean downgraded = service.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(downgraded).isTrue();
|
||||
verify(userRoleService).downgradeToFree(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"downgrades a PRO user whose team is personal (personal team is not a shared PRO source)")
|
||||
void proWithPersonalTeam_isDowngraded() {
|
||||
User u = userWithRole(Role.PRO_USER.getRoleId());
|
||||
Team t = team(7L, "alice-personal");
|
||||
u.setTeam(t);
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
|
||||
|
||||
boolean downgraded = service.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(downgraded).isTrue();
|
||||
verify(userRoleService).downgradeToFree(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps PRO (returns false) for a PRO user on a non-personal/shared team")
|
||||
void proWithSharedTeam_keepsPro() {
|
||||
User u = userWithRole(Role.PRO_USER.getRoleId());
|
||||
Team t = team(9L, "acme-team");
|
||||
u.setTeam(t);
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(false);
|
||||
|
||||
boolean downgraded = service.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(downgraded).isFalse();
|
||||
verify(userRoleService, never()).downgradeToFree(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false without touching roles when the user is already FREE")
|
||||
void freeUser_isNoOp() {
|
||||
User u = userWithRole(Role.USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
assertThat(service.handleDowngrade(SUPABASE_ID)).isFalse();
|
||||
verify(userRoleService, never()).downgradeToFree(any());
|
||||
verifyNoInteractions(saasTeamExtensionService);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("enableMeteredBilling")
|
||||
class EnableMeteredBilling {
|
||||
|
||||
@Test
|
||||
@DisplayName("enables metered billing and returns true when it was off")
|
||||
void wasOff_enables() {
|
||||
User u = userWithRole(Role.PRO_USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
|
||||
|
||||
boolean result = service.enableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
verify(saasUserExtensionService).setMeteredBillingEnabled(u, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false and does not re-enable when metered billing is already on")
|
||||
void alreadyOn_isNoOp() {
|
||||
User u = userWithRole(Role.PRO_USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
|
||||
|
||||
boolean result = service.enableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(result).isFalse();
|
||||
verify(saasUserExtensionService, never()).setMeteredBillingEnabled(any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("disableMeteredBilling")
|
||||
class DisableMeteredBilling {
|
||||
|
||||
@Test
|
||||
@DisplayName("disables metered billing and returns true when it was on")
|
||||
void wasOn_disables() {
|
||||
User u = userWithRole(Role.USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
|
||||
|
||||
boolean result = service.disableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
verify(saasUserExtensionService).setMeteredBillingEnabled(u, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false and does nothing when metered billing is already off")
|
||||
void alreadyOff_isNoOp() {
|
||||
User u = userWithRole(Role.USER.getRoleId());
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
|
||||
|
||||
boolean result = service.disableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(result).isFalse();
|
||||
verify(saasUserExtensionService, never()).setMeteredBillingEnabled(any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronizeUserUpgrade")
|
||||
class SynchronizeUserUpgrade {
|
||||
|
||||
private static SupabaseUser supabaseUser(boolean anonymous) {
|
||||
SupabaseUser su = new SupabaseUser();
|
||||
su.setId(SUPABASE_UUID);
|
||||
su.setAnonymous(anonymous);
|
||||
return su;
|
||||
}
|
||||
|
||||
private static User anonymousLocalUser() {
|
||||
User u = new User();
|
||||
u.setUsername("anon-handle");
|
||||
u.setAuthenticationType(AuthenticationType.ANONYMOUS);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IllegalStateException when no local user is linked to the supabase id")
|
||||
void noLinkedUser_throws() {
|
||||
SupabaseUser su = supabaseUser(true);
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> service.synchronizeUserUpgrade(su, "alice@example.com", "google"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("No local user linked to Supabase ID");
|
||||
|
||||
verifyNoInteractions(supabaseUserService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("flips the supabase anonymous mirror to false and saves it")
|
||||
void anonymousMirror_isFlippedAndSaved() {
|
||||
SupabaseUser su = supabaseUser(true);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", "web");
|
||||
|
||||
assertThat(su.isAnonymous()).isFalse();
|
||||
verify(supabaseUserService).save(su);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not save the supabase mirror when it was already non-anonymous")
|
||||
void nonAnonymousMirror_isNotSaved() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", "web");
|
||||
|
||||
verify(supabaseUserService, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("promotes an anonymous local user to WEB and copies the email into username")
|
||||
void anonymousLocalUser_promotedToWeb() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
User result = service.synchronizeUserUpgrade(su, "alice@example.com", "web");
|
||||
|
||||
assertThat(result).isSameAs(u);
|
||||
// setAuthenticationType stores the enum name lowercased.
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("web");
|
||||
assertThat(u.getEmail()).isEqualTo("alice@example.com");
|
||||
assertThat(u.getUsername()).isEqualTo("alice@example.com");
|
||||
verify(userService).saveUser(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps a known OAuth provider (google) to OAUTH2 auth type")
|
||||
void oauthProvider_mapsToOauth2() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", "google");
|
||||
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps a generic 'oauth' authMethod to OAUTH2")
|
||||
void genericOauth_mapsToOauth2() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", "oauth");
|
||||
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps an unknown authMethod to WEB")
|
||||
void unknownMethod_mapsToWeb() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", "carrier-pigeon");
|
||||
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("web");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps a null authMethod to WEB")
|
||||
void nullMethod_mapsToWeb() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", null);
|
||||
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("web");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"does not overwrite username/email when the email is blank, but still promotes the type")
|
||||
void blankEmail_keepsUsername() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, " ", "google");
|
||||
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
|
||||
assertThat(u.getUsername()).isEqualTo("anon-handle");
|
||||
assertThat(u.getEmail()).isNull();
|
||||
verify(userService).saveUser(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not overwrite username/email when the email is null")
|
||||
void nullEmail_keepsUsername() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, null, "web");
|
||||
|
||||
assertThat(u.getUsername()).isEqualTo("anon-handle");
|
||||
assertThat(u.getEmail()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves a non-anonymous local user untouched and never saves it")
|
||||
void nonAnonymousLocalUser_untouched() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = new User();
|
||||
u.setUsername("existing@example.com");
|
||||
u.setAuthenticationType(AuthenticationType.WEB);
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
|
||||
User result = service.synchronizeUserUpgrade(su, "new@example.com", "google");
|
||||
|
||||
assertThat(result).isSameAs(u);
|
||||
assertThat(u.getUsername()).isEqualTo("existing@example.com");
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("web");
|
||||
verify(userService, never()).saveUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("anonymous comparison is case-insensitive (stored type is lowercased)")
|
||||
void anonymousTypeMatchesCaseInsensitively() {
|
||||
SupabaseUser su = supabaseUser(false);
|
||||
User u = anonymousLocalUser();
|
||||
// sanity: the entity stored the lowercase form, exercising the equalsIgnoreCase branch
|
||||
assertThat(u.getAuthenticationType()).isEqualTo("anonymous");
|
||||
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
|
||||
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.synchronizeUserUpgrade(su, "alice@example.com", "web");
|
||||
|
||||
verify(userService).saveUser(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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 = "user@example.com";
|
||||
|
||||
private static SupabaseUser supabaseUser(UUID id, String email, boolean anonymous) {
|
||||
SupabaseUser u = new SupabaseUser();
|
||||
u.setId(id);
|
||||
u.setEmail(email);
|
||||
u.setAnonymous(anonymous);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getUser")
|
||||
class GetUser {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the entity when the repository finds it by id")
|
||||
void found_returnsEntity() {
|
||||
SupabaseUser existing = supabaseUser(SUPABASE_ID, EMAIL, false);
|
||||
when(supabaseUserRepository.findById(SUPABASE_ID)).thenReturn(Optional.of(existing));
|
||||
|
||||
SupabaseUser result = service.getUser(SUPABASE_ID);
|
||||
|
||||
assertThat(result).isSameAs(existing);
|
||||
verify(supabaseUserRepository).findById(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws UserNotFoundException carrying the id when the repository is empty")
|
||||
void missing_throwsUserNotFound() {
|
||||
when(supabaseUserRepository.findById(SUPABASE_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.getUser(SUPABASE_ID))
|
||||
.isInstanceOf(UserNotFoundException.class)
|
||||
.hasMessageContaining(SUPABASE_ID.toString())
|
||||
.hasMessageContaining("not found");
|
||||
|
||||
verify(supabaseUserRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("looks the user up by the exact id it is given")
|
||||
void passesIdThrough() {
|
||||
UUID other = UUID.fromString("99999999-8888-7777-6666-555555555555");
|
||||
when(supabaseUserRepository.findById(other))
|
||||
.thenReturn(Optional.of(supabaseUser(other, "other@example.com", true)));
|
||||
|
||||
SupabaseUser result = service.getUser(other);
|
||||
|
||||
assertThat(result.getId()).isEqualTo(other);
|
||||
verify(supabaseUserRepository).findById(other);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSupabaseUser")
|
||||
class CreateSupabaseUser {
|
||||
|
||||
@Test
|
||||
@DisplayName("builds a SupabaseUser from the args and returns the saved entity")
|
||||
void buildsAndSavesUser() {
|
||||
// Repository echoes whatever it was handed.
|
||||
when(supabaseUserRepository.save(any(SupabaseUser.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
SupabaseUser result = service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
|
||||
|
||||
ArgumentCaptor<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");
|
||||
}
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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, "alice@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.changeRole(u, ROLE_PRO_USER);
|
||||
|
||||
// Authority entity carries the new role and is saved.
|
||||
assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
|
||||
verify(authorityRepository).save(auth);
|
||||
// Denormalized column mirrored on the User entity and saved.
|
||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
||||
verify(userRepository).save(u);
|
||||
// No credit reset on the bare changeRole path.
|
||||
verify(creditService, never())
|
||||
.resetCycleAllocationForRoleChange(Mockito.anyLong(), Mockito.anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("persists the authority before the user (authority-first ordering)")
|
||||
void persistsAuthorityBeforeUser() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "alice@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.changeRole(u, ROLE_PRO_USER);
|
||||
|
||||
InOrder order = Mockito.inOrder(authorityRepository, userRepository);
|
||||
order.verify(authorityRepository).save(auth);
|
||||
order.verify(userRepository).save(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("looks the authority up by the user's numeric id")
|
||||
void looksUpAuthorityByUserId() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(99L, "bob@example.com", ROLE_PRO_USER);
|
||||
Authority auth = authority(ROLE_PRO_USER);
|
||||
when(authorityRepository.findByUserId(99L)).thenReturn(auth);
|
||||
|
||||
service.changeRole(u, ROLE_USER);
|
||||
|
||||
verify(authorityRepository).findByUserId(99L);
|
||||
assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
|
||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setting the same role is a harmless no-op rewrite that still persists")
|
||||
void sameRoleStillPersists() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "carol@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.changeRole(u, ROLE_USER);
|
||||
|
||||
assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
|
||||
verify(authorityRepository).save(auth);
|
||||
verify(userRepository).save(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")")
|
||||
void emptyAuthoritiesSetIsTolerated() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "dave@example.com", null);
|
||||
// getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log.
|
||||
Authority auth = authority(null);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.changeRole(u, ROLE_PRO_USER);
|
||||
|
||||
assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
|
||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("downgradeToFree")
|
||||
class DowngradeToFree {
|
||||
|
||||
@Test
|
||||
@DisplayName("sets ROLE_USER and resets credits to the configured FREE allocation (50)")
|
||||
void setsUserRoleAndResetsCreditsFromConfig() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "eve@example.com", ROLE_PRO_USER);
|
||||
Authority auth = authority(ROLE_PRO_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.downgradeToFree(u);
|
||||
|
||||
assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
|
||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
|
||||
verify(authorityRepository).save(auth);
|
||||
verify(userRepository).save(u);
|
||||
// Config map has ROLE_USER=50, so getOrDefault returns 50 (NOT the inline 25 fallback).
|
||||
verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the inline 25 fallback when the allocations map omits ROLE_USER")
|
||||
void usesInlineFallbackWhenAllocationMissing() {
|
||||
UserRoleService service = service(emptyAllocationsProps());
|
||||
User u = user(42L, "frank@example.com", ROLE_PRO_USER);
|
||||
Authority auth = authority(ROLE_PRO_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.downgradeToFree(u);
|
||||
|
||||
verify(creditService).resetCycleAllocationForRoleChange(42L, 25);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("honours an explicit ROLE_USER allocation override")
|
||||
void honoursExplicitAllocationOverride() {
|
||||
UserRoleService service = service(propsWith(Map.of(ROLE_USER, 7)));
|
||||
User u = user(42L, "grace@example.com", ROLE_PRO_USER);
|
||||
Authority auth = authority(ROLE_PRO_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.downgradeToFree(u);
|
||||
|
||||
verify(creditService).resetCycleAllocationForRoleChange(42L, 7);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("changes the role before resetting credits (role flip precedes the reset)")
|
||||
void changesRoleBeforeResettingCredits() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "heidi@example.com", ROLE_PRO_USER);
|
||||
Authority auth = authority(ROLE_PRO_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.downgradeToFree(u);
|
||||
|
||||
InOrder order = Mockito.inOrder(userRepository, creditService);
|
||||
order.verify(userRepository).save(u);
|
||||
order.verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("upgradeToPro")
|
||||
class UpgradeToPro {
|
||||
|
||||
@Test
|
||||
@DisplayName("sets ROLE_PRO_USER and resets credits to the configured PRO allocation (500)")
|
||||
void setsProRoleAndResetsCreditsFromConfig() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "ivan@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.upgradeToPro(u);
|
||||
|
||||
assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
|
||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
||||
verify(authorityRepository).save(auth);
|
||||
verify(userRepository).save(u);
|
||||
// Config map has ROLE_PRO_USER=500, so getOrDefault returns 500 (NOT inline 100).
|
||||
verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the inline 100 fallback when the allocations map omits ROLE_PRO_USER")
|
||||
void usesInlineFallbackWhenAllocationMissing() {
|
||||
UserRoleService service = service(emptyAllocationsProps());
|
||||
User u = user(42L, "judy@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.upgradeToPro(u);
|
||||
|
||||
verify(creditService).resetCycleAllocationForRoleChange(42L, 100);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("honours an explicit ROLE_PRO_USER allocation override")
|
||||
void honoursExplicitAllocationOverride() {
|
||||
UserRoleService service = service(propsWith(Map.of(ROLE_PRO_USER, 9999)));
|
||||
User u = user(42L, "mallory@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.upgradeToPro(u);
|
||||
|
||||
verify(creditService).resetCycleAllocationForRoleChange(42L, 9999);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("changes the role before resetting credits (role flip precedes the reset)")
|
||||
void changesRoleBeforeResettingCredits() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
User u = user(42L, "niaj@example.com", ROLE_USER);
|
||||
Authority auth = authority(ROLE_USER);
|
||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||
|
||||
service.upgradeToPro(u);
|
||||
|
||||
InOrder order = Mockito.inOrder(userRepository, creditService);
|
||||
order.verify(userRepository).save(u);
|
||||
order.verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getCreditAllocationForRole")
|
||||
class GetCreditAllocationForRole {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the configured allocation when the role is present in the map")
|
||||
void returnsConfiguredAllocation() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
|
||||
// Default map: ROLE_USER=50, ROLE_PRO_USER=500, ROLE_ADMIN=1000.
|
||||
assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(50);
|
||||
assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(500);
|
||||
assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ROLE_USER falls back to 25 when missing from the allocations map")
|
||||
void userRoleFallsBackTo25() {
|
||||
UserRoleService service = service(emptyAllocationsProps());
|
||||
|
||||
assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("any non-ROLE_USER role falls back to 100 when missing from the map")
|
||||
void nonUserRoleFallsBackTo100() {
|
||||
UserRoleService service = service(emptyAllocationsProps());
|
||||
|
||||
assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(100);
|
||||
assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(100);
|
||||
assertThat(service.getCreditAllocationForRole("ROLE_UNKNOWN")).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null role id is treated as non-ROLE_USER and falls back to 100")
|
||||
void nullRoleIdFallsBackTo100() {
|
||||
UserRoleService service = service(emptyAllocationsProps());
|
||||
|
||||
// getOrDefault(null, ...) misses (no null key); the ternary's equals(null) is false
|
||||
// -> 100. Must not NPE because Role.USER.getRoleId().equals(roleId) is null-safe.
|
||||
assertThat(service.getCreditAllocationForRole(null)).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a configured value of zero is returned verbatim, not replaced by a fallback")
|
||||
void zeroAllocationReturnedVerbatim() {
|
||||
UserRoleService service = service(propsWith(Map.of("ROLE_WEB_ONLY_USER", 0)));
|
||||
|
||||
assertThat(service.getCreditAllocationForRole("ROLE_WEB_ONLY_USER")).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getCreditAllocationForRole never touches the persistence collaborators")
|
||||
void doesNotTouchRepositories() {
|
||||
UserRoleService service = service(defaultProps());
|
||||
|
||||
service.getCreditAllocationForRole(ROLE_USER);
|
||||
|
||||
Mockito.verifyNoInteractions(userRepository, authorityRepository, creditService);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -187,8 +187,8 @@ subprojects {
|
||||
}
|
||||
// Maven Central first; mirrors below are fallbacks for niche artifacts.
|
||||
mavenCentral()
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
@@ -585,7 +585,6 @@ repositories {
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
}
|
||||
|
||||
|
||||
@@ -2747,7 +2747,7 @@ summary = "Ran {{count}} tools"
|
||||
unknownTool = "Unknown tool"
|
||||
|
||||
[cloudBadge]
|
||||
tooltip = "Runs in the cloud (included, no extra charge)"
|
||||
tooltip = "This operation will use your cloud credits"
|
||||
|
||||
[color.eyeDropper]
|
||||
tooltip = "Pick colour from screen"
|
||||
@@ -2769,6 +2769,7 @@ error = "Error"
|
||||
expand = "Expand"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
operation = "this operation"
|
||||
preview = "Preview"
|
||||
previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
@@ -3221,25 +3222,68 @@ posthog = "PostHog Analytics"
|
||||
scarf = "Scarf Pixel"
|
||||
|
||||
[credits]
|
||||
enableOverageBilling = "Enable Overage Billing"
|
||||
maybeLater = "Maybe later"
|
||||
upgrade = "Upgrade"
|
||||
|
||||
[credits.insufficient]
|
||||
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
|
||||
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
|
||||
managedMember = "Please contact your team leader for assistance."
|
||||
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
|
||||
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
|
||||
teamMember = "Enable overage billing to never run out of credits."
|
||||
title = "Insufficient Credits"
|
||||
|
||||
[credits.modal]
|
||||
advancedMonitoring = "Advanced monitoring"
|
||||
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
|
||||
apiSandbox = "API sandbox"
|
||||
contactSales = "Contact Sales"
|
||||
creditsRemaining = "{{current}} of {{total}} remaining"
|
||||
creditsThisMonth = "Monthly credits"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
dedicatedSupportSlas = "Dedicated support & SLAs"
|
||||
enterpriseSubscription = "Enterprise"
|
||||
everythingInCredits = "Everything in Credits, plus:"
|
||||
everythingInFree = "Everything in Free, plus:"
|
||||
fasterThroughput = "10x faster throughput"
|
||||
forRegularWork = "For regular PDF work:"
|
||||
freeTier = "Free Tier"
|
||||
fullyPrivateFiles = "Fully private files"
|
||||
largeFileProcessing = "Large file processing"
|
||||
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
|
||||
managedMemberTitle = "Unlimited Credits"
|
||||
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
|
||||
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
|
||||
meteringIncluded = "{{credits}} credits/month included with Team"
|
||||
meteringNeverRunOut = "Never run out of credits"
|
||||
meteringNoCommitment = "No commitment, cancel anytime"
|
||||
meteringPayAsYouGo = "Only pay for what you use"
|
||||
meteringPrice = "Additional credits at {{price}}/credit"
|
||||
meteringTitle = "Pay-What-You-Use Overage Billing"
|
||||
monthlyCredits = "monthly credits"
|
||||
orgWideAccess = "Org-wide access controls"
|
||||
overage = "overage"
|
||||
perMonth = "/month"
|
||||
popular = "Popular"
|
||||
premiumAiModels = "Premium AI models"
|
||||
prioritySupport = "Priority support"
|
||||
privateDocCloud = "Private Document Cloud"
|
||||
ragFineTuning = "RAG + fine-tuning"
|
||||
secureApiAccess = "Secure API access"
|
||||
selfHostLink = "Review the docs and plans"
|
||||
selfHostPrompt = "Want to self host?"
|
||||
standardThroughput = "Standard throughput"
|
||||
subtitle = "Upgrade to Team for 10x the credits and faster processing."
|
||||
subtitlePro = "Enable automatic overage billing to never run out of credits."
|
||||
teamLeaderOnly = "Only team leaders can enable overage billing"
|
||||
teamSubscription = "Team"
|
||||
titleExhausted = "You've used your free credits"
|
||||
titleExhaustedPro = "You have run out of credits"
|
||||
unlimitedApiAccess = "Unlimited API access"
|
||||
unlimitedMonthlyCredits = "Site Licence"
|
||||
unlimitedSeats = "Unlimited seats"
|
||||
|
||||
[crop]
|
||||
@@ -5350,7 +5394,11 @@ resetsTomorrow = "Resets tomorrow"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
|
||||
checkoutOpened = "Checkout Opened in Browser"
|
||||
closeLater = "I'll Do This Later"
|
||||
error = "Payment Error"
|
||||
generatingLicense = "Generating your licence key..."
|
||||
licenseActivated = "Licence activated! Your licence key has been saved. A confirmation email has been sent to your registered email address."
|
||||
@@ -5366,6 +5414,7 @@ paymentCanceled = "Payment was cancelled. No charges were made."
|
||||
perMonth = "/month"
|
||||
preparing = "Preparing your checkout..."
|
||||
redirecting = "Redirecting to secure checkout..."
|
||||
refreshBilling = "I've Completed Payment - Refresh Billing"
|
||||
success = "Payment Successful!"
|
||||
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly."
|
||||
syncError = "Payment successful but licence sync failed. Your licence will be updated shortly. Please contact support if issues persist."
|
||||
@@ -5655,10 +5704,13 @@ title = "Pipeline"
|
||||
|
||||
[plan]
|
||||
contact = "Contact Us"
|
||||
currency = "Currency"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
featureComparison = "Feature Comparison"
|
||||
from = "From"
|
||||
hideComparison = "Hide Feature Comparison"
|
||||
included = "Included"
|
||||
licensedSeats = "Licensed: {{count}} seats"
|
||||
manage = "Manage"
|
||||
perMonth = "/month"
|
||||
@@ -5676,6 +5728,7 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
loadError = "Unable to load plan pricing. Using default values."
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -5686,6 +5739,7 @@ highlight3 = "Latest features"
|
||||
name = "Enterprise"
|
||||
requiresServer = "Requires Server"
|
||||
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
|
||||
siteLicense = "Site Licence"
|
||||
|
||||
[plan.feature]
|
||||
api = "API Access"
|
||||
@@ -5792,9 +5846,16 @@ success = "Licence Activated!"
|
||||
successMessage = "Your licence has been successfully activated. You can now close this window."
|
||||
|
||||
[plan.team]
|
||||
name = "Team"
|
||||
|
||||
[plan.trial]
|
||||
continueWithFree = "Continue with Free"
|
||||
expired = "Your Trial Has Ended"
|
||||
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||
message = ""
|
||||
subscribe = "Subscribe to Pro"
|
||||
subscribeToPro = "Subscribe to Pro"
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
|
||||
@@ -4,6 +4,10 @@ apply = "Apply"
|
||||
applyAndContinue = "Save & Leave"
|
||||
areYouSure = "Are you sure you want to leave?"
|
||||
back = "Back"
|
||||
wizardContinue = "Continue"
|
||||
wizardProgress = "Step progress"
|
||||
wizardStepProgress = "Step {{current}} of {{total}}"
|
||||
wizardCompleteStep = "Complete this step to continue."
|
||||
black = "Black"
|
||||
blue = "Blue"
|
||||
cancel = "Cancel"
|
||||
@@ -2743,6 +2747,7 @@ error = "Error"
|
||||
expand = "Expand"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
operation = "this operation"
|
||||
preview = "Preview"
|
||||
previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
@@ -3195,25 +3200,68 @@ posthog = "PostHog Analytics"
|
||||
scarf = "Scarf Pixel"
|
||||
|
||||
[credits]
|
||||
enableOverageBilling = "Enable Overage Billing"
|
||||
maybeLater = "Maybe later"
|
||||
upgrade = "Upgrade"
|
||||
|
||||
[credits.insufficient]
|
||||
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
|
||||
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
|
||||
managedMember = "Please contact your team leader for assistance."
|
||||
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
|
||||
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
|
||||
teamMember = "Enable overage billing to never run out of credits."
|
||||
title = "Insufficient Credits"
|
||||
|
||||
[credits.modal]
|
||||
advancedMonitoring = "Advanced monitoring"
|
||||
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
|
||||
apiSandbox = "API sandbox"
|
||||
contactSales = "Contact Sales"
|
||||
creditsRemaining = "{{current}} of {{total}} remaining"
|
||||
creditsThisMonth = "Monthly credits"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
dedicatedSupportSlas = "Dedicated support & SLAs"
|
||||
enterpriseSubscription = "Enterprise"
|
||||
everythingInCredits = "Everything in Credits, plus:"
|
||||
everythingInFree = "Everything in Free, plus:"
|
||||
fasterThroughput = "10x faster throughput"
|
||||
forRegularWork = "For regular PDF work:"
|
||||
freeTier = "Free Tier"
|
||||
fullyPrivateFiles = "Fully private files"
|
||||
largeFileProcessing = "Large file processing"
|
||||
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
|
||||
managedMemberTitle = "Unlimited Credits"
|
||||
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
|
||||
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
|
||||
meteringIncluded = "{{credits}} credits/month included with Team"
|
||||
meteringNeverRunOut = "Never run out of credits"
|
||||
meteringNoCommitment = "No commitment, cancel anytime"
|
||||
meteringPayAsYouGo = "Only pay for what you use"
|
||||
meteringPrice = "Additional credits at {{price}}/credit"
|
||||
meteringTitle = "Pay-What-You-Use Overage Billing"
|
||||
monthlyCredits = "monthly credits"
|
||||
orgWideAccess = "Org-wide access controls"
|
||||
overage = "overage"
|
||||
perMonth = "/month"
|
||||
popular = "Popular"
|
||||
premiumAiModels = "Premium AI models"
|
||||
prioritySupport = "Priority support"
|
||||
privateDocCloud = "Private Document Cloud"
|
||||
ragFineTuning = "RAG + fine-tuning"
|
||||
secureApiAccess = "Secure API access"
|
||||
selfHostLink = "Review the docs and plans"
|
||||
selfHostPrompt = "Want to self host?"
|
||||
standardThroughput = "Standard throughput"
|
||||
subtitle = "Upgrade to Team for 10x the credits and faster processing."
|
||||
subtitlePro = "Enable automatic overage billing to never run out of credits."
|
||||
teamLeaderOnly = "Only team leaders can enable overage billing"
|
||||
teamSubscription = "Team"
|
||||
titleExhausted = "You've used your free credits"
|
||||
titleExhaustedPro = "You have run out of credits"
|
||||
unlimitedApiAccess = "Unlimited API access"
|
||||
unlimitedMonthlyCredits = "Site License"
|
||||
unlimitedSeats = "Unlimited seats"
|
||||
|
||||
[crop]
|
||||
@@ -3827,6 +3875,9 @@ title = "File to PDF"
|
||||
|
||||
[fileUpload]
|
||||
dropFilesHere = "Drop files here or click the upload button"
|
||||
dropTitle = "Drop your file here"
|
||||
dropSubtitle = "or pick one from your computer"
|
||||
addAtLeast = "Add at least {{count}} files"
|
||||
dropFilesHereOpen = "Drop files here or click the open button"
|
||||
filesAvailable = "files available"
|
||||
loadFromStorage = "Load from Storage"
|
||||
@@ -5319,7 +5370,11 @@ resetsTomorrow = "Resets tomorrow"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
|
||||
checkoutOpened = "Checkout Opened in Browser"
|
||||
closeLater = "I'll Do This Later"
|
||||
error = "Payment Error"
|
||||
generatingLicense = "Generating your license key..."
|
||||
licenseActivated = "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address."
|
||||
@@ -5335,6 +5390,7 @@ paymentCanceled = "Payment was canceled. No charges were made."
|
||||
perMonth = "/month"
|
||||
preparing = "Preparing your checkout..."
|
||||
redirecting = "Redirecting to secure checkout..."
|
||||
refreshBilling = "I've Completed Payment - Refresh Billing"
|
||||
success = "Payment Successful!"
|
||||
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly."
|
||||
syncError = "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist."
|
||||
@@ -5624,10 +5680,13 @@ title = "Pipeline"
|
||||
|
||||
[plan]
|
||||
contact = "Contact Us"
|
||||
currency = "Currency"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
featureComparison = "Feature Comparison"
|
||||
from = "From"
|
||||
hideComparison = "Hide Feature Comparison"
|
||||
included = "Included"
|
||||
licensedSeats = "Licensed: {{count}} seats"
|
||||
manage = "Manage"
|
||||
perMonth = "/month"
|
||||
@@ -5645,6 +5704,7 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
loadError = "Unable to load plan pricing. Using default values."
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -5655,6 +5715,7 @@ highlight3 = "Latest features"
|
||||
name = "Enterprise"
|
||||
requiresServer = "Requires Server"
|
||||
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
|
||||
siteLicense = "Site License"
|
||||
|
||||
[plan.feature]
|
||||
api = "API Access"
|
||||
@@ -5761,8 +5822,15 @@ success = "License Activated!"
|
||||
successMessage = "Your license has been successfully activated. You can now close this window."
|
||||
|
||||
[plan.team]
|
||||
name = "Team"
|
||||
|
||||
[plan.trial]
|
||||
continueWithFree = "Continue with Free"
|
||||
expired = "Your Trial Has Ended"
|
||||
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||
subscribe = "Subscribe to Pro"
|
||||
subscribeToPro = "Subscribe to Pro"
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
@@ -6181,6 +6249,8 @@ applyWarning = "⚠️ Permanent application, cannot be undone and the data unde
|
||||
colorLabel = "Redaction Color"
|
||||
controlsTitle = "Manual Redaction Controls"
|
||||
instructions = "Select text or draw areas on the PDF to mark content for redaction."
|
||||
markArea = "Mark Area"
|
||||
markText = "Mark Text"
|
||||
title = "Redaction Tools"
|
||||
|
||||
[redact.modeSelector]
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1 +0,0 @@
|
||||
export const CLOUD_LAYER_PROBE = "cloud" as const;
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Auth/session seam (@app/auth/session). saas keeps a Supabase web session;
|
||||
* desktop keeps a JWT in the Tauri secure store — cloud code reads the token
|
||||
* through this seam instead. Default no-op; saas/ and desktop/ shadow it.
|
||||
*/
|
||||
|
||||
/** Minimal session shape shared across platforms. */
|
||||
export interface AppSession {
|
||||
/** Bearer access token for authenticated API calls, or null when signed out. */
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
/** The current access token for authenticated API calls, or null when signed out. */
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Team-session auth seam (@app/auth/teamSession). {@link SaaSTeamContext} needs
|
||||
* just two things from auth (can-use-teams + a post-membership refresh), so it
|
||||
* consumes them through this narrow seam rather than reaching a platform auth
|
||||
* surface directly. Default reports no access; saas/ and desktop/ shadow it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The minimal auth surface {@link SaaSTeamContext} depends on.
|
||||
*
|
||||
* Each platform supplies its own implementation:
|
||||
* - {@link canUseTeams} is {@code true} only for a signed-in, non-anonymous
|
||||
* user (web: Supabase non-anonymous session; desktop: a valid authService
|
||||
* token). When {@code false} the context stays empty and makes no API calls.
|
||||
* - {@link refreshAfterMembershipChange} is invoked after a membership change
|
||||
* (accepting an invite, leaving a team) so the platform can refresh any
|
||||
* derived auth-tier state. On web this refreshes credits + the Supabase
|
||||
* session; on desktop it is a no-op (no such derived state).
|
||||
*/
|
||||
export interface TeamAuth {
|
||||
/** Whether the current session may load/manage teams (signed in, not anonymous). */
|
||||
canUseTeams: boolean;
|
||||
/** Refresh derived auth state after a team membership change. */
|
||||
refreshAfterMembershipChange: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the team-relevant auth surface for the current session. Cloud default
|
||||
* reports no access + a no-op refresh; saas/desktop shadow it. Implemented as a
|
||||
* hook so the saas/desktop impls can subscribe to live auth state.
|
||||
*/
|
||||
export function useTeamAuth(): TeamAuth {
|
||||
return {
|
||||
canUseTeams: false,
|
||||
refreshAfterMembershipChange: async () => {},
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from "react";
|
||||
import { type TFunction } from "i18next";
|
||||
import {
|
||||
type ConfigNavSection,
|
||||
type ConfigNavItem,
|
||||
} from "@core/components/shared/config/configNavSections";
|
||||
import Plan from "@app/components/shared/config/configSections/Plan";
|
||||
import TeamSection from "@app/components/shared/config/configSections/TeamSection";
|
||||
|
||||
/**
|
||||
* Shared cloud config nav-section builders, composed by both the saas (web) and
|
||||
* desktop (Tauri) nav wrappers. The Plan (wallet-driven PAYG dashboard + spend
|
||||
* cap) and Team sections are identical across platforms; each leaf owns its own
|
||||
* modal chrome and appends its leaf-only sections around these.
|
||||
*/
|
||||
|
||||
type Translate = TFunction<"translation", undefined>;
|
||||
|
||||
/** The Plan (billing) nav item — wallet-driven PAYG dashboard + spend cap. */
|
||||
export function createCloudPlanNavItem(t: Translate): ConfigNavItem {
|
||||
return {
|
||||
key: "plan",
|
||||
label: t("config.plan", "Plan"),
|
||||
icon: "credit-card",
|
||||
component: <Plan />,
|
||||
};
|
||||
}
|
||||
|
||||
/** The Team nav item — shared SaaS team management (invite/rename/members). */
|
||||
export function createCloudTeamNavItem(t: Translate): ConfigNavItem {
|
||||
return {
|
||||
key: "teams",
|
||||
label: t("config.team", "Team"),
|
||||
icon: "groups-rounded",
|
||||
component: <TeamSection />,
|
||||
};
|
||||
}
|
||||
|
||||
/** Billing nav section wrapping the Plan item, for leaves that group it (saas). */
|
||||
export function createCloudBillingSection(t: Translate): ConfigNavSection {
|
||||
return {
|
||||
title: "Billing",
|
||||
items: [createCloudPlanNavItem(t)],
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Wallet dev-preview seam (@app/hooks/walletDevPreview).
|
||||
*
|
||||
* The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas
|
||||
* (web) and desktop (Tauri) leaves, so it must stay platform-portable: it can't
|
||||
* read {@code import.meta.env}, {@code window.location} or web storage directly
|
||||
* (the cloud ESLint guardrail enforces this). The PAYG dev-preview route
|
||||
* ({@code /dev/payg-preview}) is a saas-only local-design affordance that
|
||||
* synthesises a wallet from {@code localStorage} when the real backend isn't
|
||||
* mounted — all three of those banned reads. {@link useWallet} reaches that
|
||||
* affordance through this seam instead.
|
||||
*
|
||||
* This module is the DEFAULT + the shared TypeScript contract: it reports
|
||||
* "not a dev preview" so the real backend fetch always runs. The saas leaf
|
||||
* shadows it with saas/hooks/walletDevPreview.ts, which supplies the synthesis;
|
||||
* desktop has no dev-preview route, so the cascade falls through to this
|
||||
* default. Returning {@code null} from {@link getWalletDevPreview} is the
|
||||
* canonical "no dev preview active" signal.
|
||||
*/
|
||||
import type { Wallet, WalletRole } from "@app/hooks/useWallet";
|
||||
|
||||
/**
|
||||
* The dev-preview side-channel {@link useWallet} consumes when rendered outside
|
||||
* the real app (the saas {@code /dev/payg-preview} route). When active it stands
|
||||
* in for the backend: {@link buildWallet} synthesises the snapshot and
|
||||
* {@link markSubscribed} flips the simulated subscription state. {@code null}
|
||||
* (the cloud default + every desktop build) means "no dev preview — fetch the
|
||||
* real wallet".
|
||||
*/
|
||||
export interface WalletDevPreview {
|
||||
/** Synthesise the dev-preview wallet snapshot (subscription state from storage). */
|
||||
buildWallet: (role: WalletRole) => Wallet;
|
||||
/** Best-effort role read for the preview — flips per {@code ?role=member}. */
|
||||
role: () => WalletRole;
|
||||
/** Flip the simulated subscription state to subscribed (persisted across reload). */
|
||||
markSubscribed: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active dev-preview side-channel, or {@code null} when we're in a
|
||||
* real build / on a real route. The cloud default + desktop always return
|
||||
* {@code null}; the saas leaf returns a live channel only on the dev route.
|
||||
*/
|
||||
export function getWalletDevPreview(): WalletDevPreview | null {
|
||||
return null;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Open-external-URL seam (@app/platform/openExternal).
|
||||
*
|
||||
* The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas
|
||||
* (web) and desktop (Tauri) leaves. Opening a URL in the user's real browser
|
||||
* differs per platform — saas hands it to the browser via window.open /
|
||||
* location.assign, desktop hands it to the OS via the Tauri shell plugin so it
|
||||
* escapes the embedded webview. Cloud code must not reach either of those
|
||||
* directly, so it opens external URLs through this seam.
|
||||
*
|
||||
* This module is the DEFAULT + the shared TypeScript contract. Real builds
|
||||
* shadow it with saas/platform/openExternal.ts and desktop/platform/
|
||||
* openExternal.ts; this default body is only reached by the cloud-standalone
|
||||
* typecheck, so it throws to make an accidental real-build resolution loud.
|
||||
*/
|
||||
|
||||
/** Opens an external URL in the user's system browser. */
|
||||
export type OpenExternal = (url: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Opens an external URL in the user's system browser. Each platform supplies
|
||||
* its own implementation; this default is never reached in a real build.
|
||||
*/
|
||||
export const openExternal: OpenExternal = async (
|
||||
_url: string,
|
||||
): Promise<void> => {
|
||||
throw new Error("openExternal: platform impl required");
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Billing data seam (@app/services/billing).
|
||||
*
|
||||
* Creating Stripe Checkout / Customer Portal sessions touches platform-specific
|
||||
* transport (saas: supabase-js web client; desktop: Tauri native HTTP with an
|
||||
* explicit bearer + deep-link return URL). Cloud code can't reach those
|
||||
* directly, so it mints sessions through this seam. This module is the DEFAULT +
|
||||
* shared contract; saas/services/billing.ts and desktop/services/billing.ts
|
||||
* shadow it. The default bodies throw so an accidental real-build resolution is
|
||||
* loud (only the cloud-standalone typecheck reaches them).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parameters for {@link createCheckoutSession}, which drives the PAYG
|
||||
* {@code create-checkout-session} edge function (see StripeCheckoutPanel). The
|
||||
* function runs outside Spring Security, so it needs the caller's {@link teamId}
|
||||
* (it can't resolve the team from the JWT alone). The platform impl supplies the
|
||||
* return URL itself (browser origin on web, deep-link scheme on desktop), so it
|
||||
* is intentionally NOT part of this shape.
|
||||
*/
|
||||
export interface CheckoutParams {
|
||||
/** The caller's team id. Required — scopes the PAYG subscription. */
|
||||
teamId: number;
|
||||
/** Lower-case 3-letter ISO currency (e.g. {@code "gbp"}). Selects the Stripe Price. */
|
||||
currency?: string;
|
||||
/** Billing email for the Checkout Session; maps to Stripe {@code customer_email} when the team has no customer yet. */
|
||||
billingOwnerEmail?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link createCheckoutSession}. Embedded checkout yields a
|
||||
* {@code clientSecret}; hosted checkout yields a {@code url}. Exactly one is set.
|
||||
*/
|
||||
export interface CheckoutSession {
|
||||
/** Stripe Checkout Session client secret for embedded mode. */
|
||||
clientSecret?: string;
|
||||
/** Hosted Stripe Checkout URL for redirect mode. */
|
||||
url?: string;
|
||||
/** Non-prod sentinel: a stubbed secret (prefixed {@code cs_mock_}) renders a placeholder instead of a real iframe. */
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
/** Result of {@link createPortalSession}. */
|
||||
export interface PortalSession {
|
||||
/** Stripe Customer Portal URL to send the user to. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for {@link createPortalSession}. The PAYG portal edge function
|
||||
* needs the caller's {@code teamId} (runs outside Spring Security).
|
||||
*/
|
||||
export interface PortalParams {
|
||||
/** The caller's team id; required by the PAYG portal edge function. */
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
/** Create a Stripe Checkout Session via the SaaS billing backend (platform impl required). */
|
||||
export async function createCheckoutSession(
|
||||
_params: CheckoutParams,
|
||||
): Promise<CheckoutSession> {
|
||||
throw new Error("billing: platform impl required");
|
||||
}
|
||||
|
||||
/** Mint a Stripe Customer Portal session via the SaaS billing backend (platform impl required). */
|
||||
export async function createPortalSession(
|
||||
_params: PortalParams,
|
||||
): Promise<PortalSession> {
|
||||
throw new Error("billing: platform impl required");
|
||||
}
|
||||
|
||||
/**
|
||||
* The Stripe publishable key used to initialise {@code loadStripe()} for
|
||||
* embedded checkout. Sourced through the seam because cloud code may not read
|
||||
* {@code import.meta.env} directly. The cloud default returns "" so the checkout
|
||||
* component falls back to its mock placeholder rather than throwing.
|
||||
*/
|
||||
export function getStripePublishableKey(): string {
|
||||
return "";
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@app/*": ["src/cloud/*", "src/proprietary/*", "src/core/*"],
|
||||
"@cloud/*": ["src/cloud/*"],
|
||||
"@proprietary/*": ["src/proprietary/*"],
|
||||
"@core/*": ["src/core/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"../global.d.ts",
|
||||
"../*.js",
|
||||
"../*.ts",
|
||||
"../*.tsx",
|
||||
"../core/setupTests.ts",
|
||||
"."
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
import { GoogleDriveIcon } from "@shared/components/CloudStorageIcons";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useIndexedDBRevision,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@shared/components/CloudStorageIcons";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
|
||||
@@ -6,7 +6,7 @@ import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@shared/components/FileDocIcon";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Core stub for SaaS Teams Section
|
||||
* Desktop layer provides the real implementation
|
||||
* This component only appears in SaaS mode
|
||||
*/
|
||||
export function SaaSTeamsSection() {
|
||||
// Core stub - return null (no team management in web builds)
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for SaasPlanSection
|
||||
* This component returns null in non-desktop builds
|
||||
* The desktop layer shadows this with the real implementation
|
||||
*/
|
||||
export function SaasPlanSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { FileDocIcon } from "@shared/components/FileDocIcon";
|
||||
import type { FileDocVariant } from "@shared/components/FileDocIcon";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Core stub for Credit Exhausted Modal
|
||||
* Desktop build overrides this with actual modal implementation
|
||||
*/
|
||||
|
||||
interface CreditExhaustedModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreditExhaustedModal(_props: CreditExhaustedModalProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Core stub for Insufficient Credits Modal
|
||||
* Desktop build overrides this with actual modal implementation
|
||||
*/
|
||||
|
||||
interface InsufficientCreditsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
toolId?: string;
|
||||
requiredCredits?: number;
|
||||
}
|
||||
|
||||
export function InsufficientCreditsModal(
|
||||
_props: InsufficientCreditsModalProps,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -66,7 +66,18 @@ export default function ToolPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
styles={{
|
||||
// Let the tool fill the panel height so the slide wizard can
|
||||
// center its content and pin its CTAs to the bottom.
|
||||
content: {
|
||||
minHeight: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { Button, Stack, Text, Divider, ColorInput } from "@mantine/core";
|
||||
import { Button, Stack, Text, Divider, ColorInput, Group } from "@mantine/core";
|
||||
import HighlightAltIcon from "@mui/icons-material/HighlightAlt";
|
||||
import CropFreeIcon from "@mui/icons-material/CropFree";
|
||||
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
@@ -21,7 +23,8 @@ export default function ManualRedactionControls({
|
||||
|
||||
// Use our RedactionContext which bridges to EmbedPDF
|
||||
const {
|
||||
activateManualRedact,
|
||||
activateTextSelection,
|
||||
activateMarquee,
|
||||
redactionsApplied,
|
||||
setActiveType,
|
||||
setManualRedactColor,
|
||||
@@ -50,9 +53,9 @@ export default function ManualRedactionControls({
|
||||
// Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
// Keep redaction tool active at all times while this component is mounted.
|
||||
// If anything deactivates it (annotation tools, text selection, file switch, etc.)
|
||||
// this re-enables it automatically — no manual "Activate" button needed.
|
||||
// Keep a redaction tool active while this component is mounted. If anything
|
||||
// deactivates it (annotation tools, file switch, etc.) this re-enables it —
|
||||
// defaulting to text selection so "Mark Text" is the initial active mode.
|
||||
useEffect(() => {
|
||||
if (
|
||||
disabled ||
|
||||
@@ -77,7 +80,7 @@ export default function ManualRedactionControls({
|
||||
// Small delay to avoid racing with EmbedPDF's own state updates
|
||||
const timer = setTimeout(() => {
|
||||
if (!isSavingRef.current) {
|
||||
activateManualRedact();
|
||||
activateTextSelection();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -90,7 +93,7 @@ export default function ManualRedactionControls({
|
||||
showNavigationWarning,
|
||||
setAnnotationMode,
|
||||
signatureApiRef,
|
||||
activateManualRedact,
|
||||
activateTextSelection,
|
||||
]);
|
||||
|
||||
// Reset redaction tool when switching between files
|
||||
@@ -123,6 +126,28 @@ export default function ManualRedactionControls({
|
||||
|
||||
const isApiReady = isBridgeReady;
|
||||
|
||||
// Which manual-redaction sub-mode is active (drives the tool button highlight).
|
||||
const isSelectionActive = activeType === "redactSelection";
|
||||
const isMarqueeActive = activeType === "marqueeRedact";
|
||||
|
||||
// Switch to a redaction sub-mode, first stepping out of annotation mode/tools.
|
||||
const switchMode = useCallback(
|
||||
(activate: () => void) => {
|
||||
if (isAnnotationMode) {
|
||||
setAnnotationMode(false);
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log("Unable to deactivate annotation tools:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
activate();
|
||||
},
|
||||
[isAnnotationMode, setAnnotationMode, signatureApiRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
@@ -138,6 +163,54 @@ export default function ManualRedactionControls({
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Group gap="sm" grow wrap="nowrap">
|
||||
<Button
|
||||
variant={
|
||||
isSelectionActive && !isAnnotationMode ? "filled" : "outline"
|
||||
}
|
||||
color={isSelectionActive && !isAnnotationMode ? "blue" : "gray"}
|
||||
leftSection={
|
||||
<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />
|
||||
}
|
||||
onClick={() => switchMode(activateTextSelection)}
|
||||
disabled={disabled || !isApiReady}
|
||||
size="sm"
|
||||
styles={{
|
||||
root: { minWidth: 0 },
|
||||
label: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("redact.manual.markText", "Mark Text")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={
|
||||
isMarqueeActive && !isAnnotationMode ? "filled" : "outline"
|
||||
}
|
||||
color={isMarqueeActive && !isAnnotationMode ? "blue" : "gray"}
|
||||
leftSection={
|
||||
<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />
|
||||
}
|
||||
onClick={() => switchMode(activateMarquee)}
|
||||
disabled={disabled || !isApiReady}
|
||||
size="sm"
|
||||
styles={{
|
||||
root: { minWidth: 0 },
|
||||
label: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("redact.manual.markArea", "Mark Area")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ColorInput
|
||||
label={t("redact.manual.colorLabel", "Redaction Colour")}
|
||||
value={manualRedactColor}
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface ReviewToolStepProps<TParams = unknown> {
|
||||
onCollapsedClick?: () => void;
|
||||
}
|
||||
|
||||
function ReviewStepContent<TParams = unknown>({
|
||||
export function ReviewStepContent<TParams = unknown>({
|
||||
operation,
|
||||
onFileClick,
|
||||
onUndo,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Slide-based tool wizard: a calm segmented progress bar, one step per slide
|
||||
with centered content, and big Back / Continue CTAs pinned to the bottom. */
|
||||
|
||||
.tool-step-wizard {
|
||||
/* Fill the panel height (the parent ScrollArea content is a flex column with
|
||||
min-height:100%). flex-basis:0 makes this resolve to exactly the viewport
|
||||
height so the body can scroll internally and the CTAs stay pinned. */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-step-wizard__title {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.75rem 0;
|
||||
}
|
||||
|
||||
/* ---- Progress bar ---- */
|
||||
.tool-step-wizard__progress {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-step-wizard__segment {
|
||||
all: unset;
|
||||
flex: 1 1 0;
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--onboarding-step-inactive);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-step-wizard__segment[data-jumpable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-step-wizard__segment-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--mantine-color-blue-filled);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-step-wizard__segment[data-filled] .tool-step-wizard__segment-fill {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.tool-step-wizard__caption {
|
||||
flex-shrink: 0;
|
||||
padding: 0 1rem 0.25rem;
|
||||
}
|
||||
|
||||
/* ---- Slide body: top-aligned content that scrolls when tall ---- */
|
||||
.tool-step-wizard__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Horizontal padding lives on the slide + CTA buttons so the CTA's top
|
||||
border can span edge-to-edge. */
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-step-wizard__slide {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 0 1rem;
|
||||
animation: wizard-slide-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes wizard-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-step-wizard__slide-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ---- CTAs: sit right after the content, stick to the bottom on overflow ---- */
|
||||
.tool-step-wizard__cta {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
/* Flex column so each button stretches to the container width minus its own
|
||||
horizontal margin (mx="md") — never width:100% + margin, which overflows. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.75rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-toolbar);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tool-step-wizard__cta-btn {
|
||||
min-height: 2.85rem !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-step-wizard__segment-fill,
|
||||
.tool-step-wizard__slide {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box, Button, Flex, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import {
|
||||
ToolWorkflowTitle,
|
||||
ToolWorkflowTitleProps,
|
||||
} from "@app/components/tools/shared/ToolWorkflowTitle";
|
||||
import type { TooltipTip } from "@app/types/tips";
|
||||
import "@app/components/tools/shared/ToolStepWizard.css";
|
||||
|
||||
export interface WizardSlide {
|
||||
key: string;
|
||||
title: string;
|
||||
content: React.ReactNode;
|
||||
/** Whether the user may advance past this slide (gates Continue + forward jumps). */
|
||||
isValid: boolean;
|
||||
/** Tooltip shown on the Continue button when this slide blocks advancing. */
|
||||
blockedHint?: string;
|
||||
/**
|
||||
* Hide the bottom Continue button for this slide — used by the Files slide,
|
||||
* whose own upload CTA advances the wizard once enough files are added.
|
||||
*/
|
||||
hideContinue?: boolean;
|
||||
/**
|
||||
* Invoked when the user navigates to this slide by clicking its progress-bar
|
||||
* segment in terminal (results) mode — used to reset the results so the tool
|
||||
* returns to editing this step.
|
||||
*/
|
||||
onActivate?: () => void;
|
||||
tooltip?: {
|
||||
content?: React.ReactNode;
|
||||
tips?: TooltipTip[];
|
||||
header?: {
|
||||
title: string;
|
||||
logo?: React.ReactNode;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolStepWizardProps {
|
||||
slides: WizardSlide[];
|
||||
title?: ToolWorkflowTitleProps;
|
||||
/** Primary CTA on the final slide (the execute button). */
|
||||
executeSlot?: React.ReactNode;
|
||||
/** Rendered just beneath the execute button on the final slide. */
|
||||
belowExecuteButton?: React.ReactNode;
|
||||
/** Optional preview content shown on the final slide above the CTA. */
|
||||
preview?: React.ReactNode;
|
||||
/**
|
||||
* Terminal (results) mode: the flow is complete, so the wizard sits on the
|
||||
* last slide with the whole progress bar filled and no Continue/Back CTAs (the
|
||||
* review content carries its own undo/download). Clicking an earlier bar
|
||||
* segment fires that slide's onActivate (reset) to return to editing it.
|
||||
*/
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tool's steps as a slide wizard: a segmented progress bar at the top,
|
||||
* one step per slide with centered content, and large Back / Continue CTAs at
|
||||
* the bottom. The final slide swaps Continue for the tool's execute button. A
|
||||
* single-step flow drops the progress bar / step counter / title and just shows
|
||||
* the centered content and its primary action.
|
||||
*
|
||||
* Navigation is gated: Continue and forward jumps are blocked until the current
|
||||
* slide is valid; Back (and clicking an earlier segment) is always allowed.
|
||||
*/
|
||||
export function ToolStepWizard({
|
||||
slides,
|
||||
title,
|
||||
executeSlot,
|
||||
belowExecuteButton,
|
||||
preview,
|
||||
terminal = false,
|
||||
}: ToolStepWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeKey, setActiveKey] = useState<string>(() => slides[0]?.key);
|
||||
|
||||
// Keep activeKey valid as the slide set changes (e.g. the Files slide drops
|
||||
// out once a file is selected). Falls back to the first slide.
|
||||
useEffect(() => {
|
||||
if (slides.length > 0 && !slides.some((s) => s.key === activeKey)) {
|
||||
setActiveKey(slides[0].key);
|
||||
}
|
||||
}, [slides, activeKey]);
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
// In terminal (results) mode the flow is complete: lock onto the last slide.
|
||||
const rawIndex = slides.findIndex((s) => s.key === activeKey);
|
||||
const currentIndex = terminal
|
||||
? slides.length - 1
|
||||
: rawIndex < 0
|
||||
? 0
|
||||
: rawIndex;
|
||||
const current = slides[currentIndex];
|
||||
const isLast = currentIndex === slides.length - 1;
|
||||
const canAdvance = current.isValid;
|
||||
const showBack = !terminal && currentIndex > 0;
|
||||
// A single-step flow is just centered content + its action — no progress bar,
|
||||
// step counter, or title (a lone full-width segment reads as an odd solid
|
||||
// line). The chrome appears only with 2+ steps.
|
||||
const showChrome = slides.length > 1;
|
||||
const showContinue = !isLast && !current.hideContinue;
|
||||
const hasCta = isLast
|
||||
? Boolean(executeSlot) || Boolean(belowExecuteButton)
|
||||
: showContinue || showBack;
|
||||
|
||||
// Forward jumps require every intervening slide to be valid; back is free.
|
||||
// In terminal (results) mode every earlier step is reachable to go back and edit.
|
||||
const canJumpTo = (target: number) => {
|
||||
if (terminal) return target < currentIndex;
|
||||
if (target === currentIndex) return false;
|
||||
if (target < currentIndex) return true;
|
||||
for (let i = currentIndex; i < target; i++) {
|
||||
if (!slides[i].isValid) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const goTo = (target: number) => {
|
||||
if (target < 0 || target >= slides.length) return;
|
||||
// In terminal mode, navigating back resets the results (returns to editing).
|
||||
if (terminal) slides[target].onActivate?.();
|
||||
setActiveKey(slides[target].key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tool-step-wizard">
|
||||
{title && (
|
||||
<div className="tool-step-wizard__title">
|
||||
<ToolWorkflowTitle {...title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showChrome && (
|
||||
<div
|
||||
className="tool-step-wizard__progress"
|
||||
role="tablist"
|
||||
aria-label={t("wizardProgress", "Step progress")}
|
||||
>
|
||||
{slides.map((slide, index) => {
|
||||
const filled = index <= currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
const jumpable = canJumpTo(index);
|
||||
return (
|
||||
<button
|
||||
key={slide.key}
|
||||
type="button"
|
||||
className="tool-step-wizard__segment"
|
||||
data-filled={filled || undefined}
|
||||
data-current={isCurrent || undefined}
|
||||
data-jumpable={jumpable || undefined}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
aria-label={`${t(
|
||||
"wizardStepProgress",
|
||||
"Step {{current}} of {{total}}",
|
||||
{
|
||||
current: index + 1,
|
||||
total: slides.length,
|
||||
},
|
||||
)}: ${slide.title}`}
|
||||
disabled={!jumpable && !isCurrent}
|
||||
onClick={() => jumpable && goTo(index)}
|
||||
>
|
||||
<span className="tool-step-wizard__segment-fill" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showChrome && (
|
||||
<Text
|
||||
className="tool-step-wizard__caption"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
>
|
||||
{t("wizardStepProgress", "Step {{current}} of {{total}}", {
|
||||
current: currentIndex + 1,
|
||||
total: slides.length,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="tool-step-wizard__body">
|
||||
<div className="tool-step-wizard__slide" key={current.key}>
|
||||
{showChrome &&
|
||||
(current.tooltip ? (
|
||||
<Tooltip
|
||||
content={current.tooltip.content}
|
||||
tips={current.tooltip.tips}
|
||||
header={current.tooltip.header}
|
||||
sidebarTooltip={true}
|
||||
pinOnClick={true}
|
||||
>
|
||||
<Flex align="center" justify="center" gap="xs">
|
||||
<Text fw={600} size="md">
|
||||
{current.title}
|
||||
</Text>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--icon-files-color)" }}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fw={600} size="md" ta="center">
|
||||
{current.title}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<div className="tool-step-wizard__slide-content">
|
||||
{current.content}
|
||||
</div>
|
||||
|
||||
{isLast && preview && <div>{preview}</div>}
|
||||
</div>
|
||||
|
||||
{hasCta && (
|
||||
<div className="tool-step-wizard__cta">
|
||||
{isLast ? (
|
||||
(executeSlot ?? null)
|
||||
) : showContinue ? (
|
||||
!canAdvance && current.blockedHint ? (
|
||||
<Tooltip content={current.blockedHint} position="top" arrow>
|
||||
<Box mx="md" mt="md">
|
||||
<Button
|
||||
fullWidth
|
||||
disabled
|
||||
size="md"
|
||||
color="blue"
|
||||
className="tool-step-wizard__cta-btn"
|
||||
data-testid="wizard-continue"
|
||||
>
|
||||
{t("wizardContinue", "Continue")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
mx="md"
|
||||
mt="md"
|
||||
size="md"
|
||||
color="blue"
|
||||
disabled={!canAdvance}
|
||||
onClick={() => canAdvance && goTo(currentIndex + 1)}
|
||||
className="tool-step-wizard__cta-btn"
|
||||
data-testid="wizard-continue"
|
||||
>
|
||||
{t("wizardContinue", "Continue")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
{isLast && belowExecuteButton}
|
||||
{showBack && (
|
||||
<Button
|
||||
mx="md"
|
||||
mt="sm"
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={() => goTo(currentIndex - 1)}
|
||||
className="tool-step-wizard__cta-btn"
|
||||
data-testid="wizard-back"
|
||||
>
|
||||
{t("back", "Back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolStepWizard;
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Large, clean upload CTA used as the Files slide in the tool step wizard. */
|
||||
|
||||
.wizard-files-dropzone {
|
||||
width: 100%;
|
||||
border: 1.5px dashed var(--border-subtle, var(--mantine-color-default-border));
|
||||
border-radius: var(--mantine-radius-lg, 1rem);
|
||||
background: transparent;
|
||||
padding: 2.25rem 1.25rem;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.wizard-files-dropzone:hover {
|
||||
border-color: var(--mantine-color-blue-filled);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 5%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.wizard-files-dropzone[data-accept] {
|
||||
border-color: var(--mantine-color-blue-filled);
|
||||
background: var(--mantine-color-blue-light);
|
||||
}
|
||||
|
||||
.wizard-files-dropzone[data-reject] {
|
||||
border-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.wizard-files-dropzone__icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { StirlingFile } from "@app/types/fileContext";
|
||||
import "@app/components/tools/shared/WizardFilesStep.css";
|
||||
|
||||
export interface WizardFilesStepProps {
|
||||
selectedFiles: StirlingFile[];
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Files slide of the tool step wizard: a large drag-and-drop upload CTA.
|
||||
* Adding enough files auto-advances the wizard (the slide drops out), so this
|
||||
* slide owns the upload action and has no Continue button of its own.
|
||||
*/
|
||||
export function WizardFilesStep({
|
||||
selectedFiles,
|
||||
minFiles = 1,
|
||||
}: WizardFilesStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const { addFiles } = useFileHandler();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const terminology = useFileActionTerminology();
|
||||
|
||||
const handleDrop = (files: File[]) => {
|
||||
if (files.length > 0) void addFiles(files);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = ".pdf,application/pdf";
|
||||
input.onchange = (event) => {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
if (files.length > 0) void addFiles(files);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const needsMore = minFiles > 1;
|
||||
const count = selectedFiles.length;
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={handleDrop}
|
||||
multiple
|
||||
activateOnClick={false}
|
||||
enablePointerEvents
|
||||
className="wizard-files-dropzone"
|
||||
aria-label={terminology.dropFilesHere}
|
||||
>
|
||||
<Stack align="center" gap="xs">
|
||||
<div className="wizard-files-dropzone__icon">
|
||||
<LocalIcon icon="upload" width="1.6rem" height="1.6rem" />
|
||||
</div>
|
||||
<Text fw={600} size="lg" ta="center">
|
||||
{t("fileUpload.dropTitle", "Drop your file here")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{needsMore
|
||||
? t("fileUpload.addAtLeast", "Add at least {{count}} files", {
|
||||
count: minFiles,
|
||||
})
|
||||
: t("fileUpload.dropSubtitle", "or pick one from your computer")}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("filesSelected", "{{count}} files", { count })}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="sm" justify="center" mt="sm">
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="upload"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
style={{ color: "white" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{terminology.uploadFromComputer}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => openFilesModal()}
|
||||
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
|
||||
>
|
||||
{terminology.addFiles}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardFilesStep;
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
ToolStepProvider,
|
||||
} from "@app/components/tools/shared/ToolStep";
|
||||
import { ScopedOperationButton } from "@app/components/tools/shared/ScopedOperationButton";
|
||||
import {
|
||||
ToolStepWizard,
|
||||
WizardSlide,
|
||||
} from "@app/components/tools/shared/ToolStepWizard";
|
||||
import { WizardFilesStep } from "@app/components/tools/shared/WizardFilesStep";
|
||||
import { ReviewStepContent } from "@app/components/tools/shared/ReviewToolStep";
|
||||
import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import {
|
||||
ToolWorkflowTitle,
|
||||
@@ -13,6 +19,7 @@ import {
|
||||
import { StirlingFile } from "@app/types/fileContext";
|
||||
import type { TooltipTip } from "@app/types/tips";
|
||||
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: StirlingFile[];
|
||||
@@ -27,6 +34,12 @@ export interface MiddleStepConfig {
|
||||
isVisible?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
/**
|
||||
* In the slide wizard, whether the user may advance past this step. Defaults
|
||||
* to true (the step is always passable). Set false to block Continue and
|
||||
* forward jumps until the step's inputs are valid.
|
||||
*/
|
||||
canContinue?: boolean;
|
||||
content: React.ReactNode;
|
||||
tooltip?: {
|
||||
content?: React.ReactNode;
|
||||
@@ -98,11 +111,141 @@ export interface ToolFlowConfig<TParams = unknown> {
|
||||
export function createToolFlow<TParams = unknown>(
|
||||
config: ToolFlowConfig<TParams>,
|
||||
) {
|
||||
const steps = createToolSteps();
|
||||
// Execute button, shared by the wizard (final-slide CTA) and the accordion.
|
||||
const renderExecuteButton = (): React.ReactNode => {
|
||||
const eb = config.executeButton;
|
||||
if (!eb || eb.isVisible === false) return null;
|
||||
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
|
||||
// Explicit disabledReason wins; otherwise derive from structured fields.
|
||||
const effectiveDisabledReason: ExecuteDisabledReason =
|
||||
eb.disabledReason !== undefined
|
||||
? eb.disabledReason
|
||||
: eb.endpointEnabled === false
|
||||
? "endpointUnavailable"
|
||||
: !hasFiles
|
||||
? "noFiles"
|
||||
: eb.paramsValid === false
|
||||
? "invalidParams"
|
||||
: null;
|
||||
return (
|
||||
<ScopedOperationButton
|
||||
selectedFiles={config.files.selectedFiles ?? []}
|
||||
disableScopeHints={eb.disableScopeHints}
|
||||
onClick={eb.onClick}
|
||||
isLoading={config.review.operation.isLoading}
|
||||
disabled={eb.disabled}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
loadingText={eb.loadingText}
|
||||
submitText={eb.text}
|
||||
showCloudBadge={
|
||||
eb.showCloudBadge ?? config.review.operation.willUseCloud ?? false
|
||||
}
|
||||
data-testid={eb.testId}
|
||||
data-tour="run-button"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Build the pre-execution slide list for the wizard ----
|
||||
const minFiles = config.files.minFiles ?? 1;
|
||||
const filesCount = config.files.selectedFiles?.length ?? 0;
|
||||
const filesSatisfied = filesCount >= minFiles;
|
||||
const filesVisible = config.files.isVisible !== false;
|
||||
// Mirror the accordion's "collapse the Files step once selected": the Files
|
||||
// slide only appears while files are still needed.
|
||||
const includeFilesSlide = filesVisible && !filesSatisfied;
|
||||
const middleSteps = config.steps.filter((s) => s.isVisible !== false);
|
||||
|
||||
const middleSlides: WizardSlide[] = middleSteps.map((step, index) => ({
|
||||
key: `step-${index}-${step.title}`,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
tooltip: step.tooltip,
|
||||
isValid: step.canContinue ?? true,
|
||||
blockedHint: i18n.t(
|
||||
"wizardCompleteStep",
|
||||
"Complete this step to continue.",
|
||||
),
|
||||
}));
|
||||
|
||||
// Key by tool identity so switching tools resets the wizard, while
|
||||
// adding/removing slides within a tool is handled by the wizard itself.
|
||||
const wizardKey = `${config.executeButton?.text ?? ""}|${middleSteps
|
||||
.map((s) => s.title)
|
||||
.join(">")}`;
|
||||
|
||||
// ---- Results view: a completed progress bar + the review content (no
|
||||
// collapsed step sections). The review is the final, terminal slide. ----
|
||||
if (config.review.isVisible) {
|
||||
// Clicking an earlier segment returns to editing that step: run the step's
|
||||
// own reset (clears preview etc.) and clear results so we leave results mode.
|
||||
const resultSlides: WizardSlide[] = [
|
||||
...middleSlides.map((slide, index) => ({
|
||||
...slide,
|
||||
onActivate: () => {
|
||||
middleSteps[index].onCollapsedClick?.();
|
||||
config.review.operation.resetResults();
|
||||
},
|
||||
})),
|
||||
{
|
||||
key: "__review__",
|
||||
title: config.review.title,
|
||||
content: (
|
||||
<ReviewStepContent
|
||||
operation={config.review.operation}
|
||||
onFileClick={config.review.onFileClick}
|
||||
onUndo={config.review.onUndo}
|
||||
/>
|
||||
),
|
||||
isValid: true,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ToolStepWizard
|
||||
key={wizardKey}
|
||||
slides={resultSlides}
|
||||
title={config.title}
|
||||
terminal
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Pre-execution flow ----
|
||||
const wizardSlides: WizardSlide[] = [];
|
||||
if (includeFilesSlide) {
|
||||
wizardSlides.push({
|
||||
key: "__files__",
|
||||
title: i18n.t("files.title", "Files"),
|
||||
content: (
|
||||
<WizardFilesStep
|
||||
selectedFiles={config.files.selectedFiles}
|
||||
minFiles={config.files.minFiles}
|
||||
/>
|
||||
),
|
||||
isValid: filesSatisfied,
|
||||
// The upload CTA advances the wizard once enough files are added.
|
||||
hideContinue: true,
|
||||
});
|
||||
}
|
||||
wizardSlides.push(...middleSlides);
|
||||
|
||||
if (wizardSlides.length >= 1) {
|
||||
return (
|
||||
<ToolStepWizard
|
||||
key={wizardKey}
|
||||
slides={wizardSlides}
|
||||
title={config.title}
|
||||
executeSlot={renderExecuteButton()}
|
||||
belowExecuteButton={config.belowExecuteButton}
|
||||
preview={filesCount > 0 ? config.preview : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Accordion fallback (rare zero-slide tool: file selected, no steps) ----
|
||||
const steps = createToolSteps();
|
||||
return (
|
||||
<Stack gap="sm" p="sm">
|
||||
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
|
||||
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
||||
{config.title && <ToolWorkflowTitle {...config.title} />}
|
||||
|
||||
@@ -131,50 +274,19 @@ export function createToolFlow<TParams = unknown>(
|
||||
|
||||
{/* Preview (outside steps, above execute button).
|
||||
Hide when review is visible or when no files are selected. */}
|
||||
{!config.review.isVisible &&
|
||||
(config.files.selectedFiles?.length ?? 0) > 0 &&
|
||||
config.preview}
|
||||
{!config.review.isVisible && filesCount > 0 && config.preview}
|
||||
|
||||
{/* Execute Button */}
|
||||
{config.executeButton &&
|
||||
config.executeButton.isVisible !== false &&
|
||||
(() => {
|
||||
const eb = config.executeButton;
|
||||
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
|
||||
// Compute the disabled reason from structured fields; explicit disabledReason wins if set.
|
||||
const effectiveDisabledReason: ExecuteDisabledReason =
|
||||
eb.disabledReason !== undefined
|
||||
? eb.disabledReason
|
||||
: eb.endpointEnabled === false
|
||||
? "endpointUnavailable"
|
||||
: !hasFiles
|
||||
? "noFiles"
|
||||
: eb.paramsValid === false
|
||||
? "invalidParams"
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<ScopedOperationButton
|
||||
selectedFiles={config.files.selectedFiles ?? []}
|
||||
disableScopeHints={eb.disableScopeHints}
|
||||
onClick={eb.onClick}
|
||||
isLoading={config.review.operation.isLoading}
|
||||
disabled={eb.disabled}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
loadingText={eb.loadingText}
|
||||
submitText={eb.text}
|
||||
showCloudBadge={
|
||||
eb.showCloudBadge ??
|
||||
config.review.operation.willUseCloud ??
|
||||
false
|
||||
}
|
||||
data-testid={eb.testId}
|
||||
data-tour="run-button"
|
||||
/>
|
||||
{config.belowExecuteButton}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const button = renderExecuteButton();
|
||||
if (!button) return null;
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{config.belowExecuteButton}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Review Step */}
|
||||
{steps.createReviewStep({
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import React, { useCallback, useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocumentState } from "@embedpdf/core/react";
|
||||
import { useScroll } from "@embedpdf/plugin-scroll/react";
|
||||
@@ -16,7 +8,6 @@ import {
|
||||
PdfActionType,
|
||||
type PdfLinkAnnoObject,
|
||||
} from "@embedpdf/models";
|
||||
import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline SVG icons (thin-stroke, modern)
|
||||
@@ -132,9 +123,7 @@ function isInternalLink(annotationLink: PdfLinkAnnoObject): boolean {
|
||||
|
||||
interface LinkToolbarProps {
|
||||
annotationLink: PdfLinkAnnoObject;
|
||||
toolbarRef: React.Ref<HTMLDivElement>;
|
||||
left: number;
|
||||
top: number;
|
||||
scale: number;
|
||||
flipped: boolean;
|
||||
onNavigate: (annotationLink: PdfLinkAnnoObject) => void;
|
||||
onDelete: (annotationLink: PdfLinkAnnoObject) => void;
|
||||
@@ -144,43 +133,11 @@ interface LinkToolbarProps {
|
||||
|
||||
const TOOLBAR_HEIGHT = 32;
|
||||
const TOOLBAR_GAP = 8;
|
||||
const TOOLBAR_EDGE_MARGIN = 4;
|
||||
const TOOLBAR_LEAVE_DELAY = 120;
|
||||
|
||||
interface ToolbarPlacement {
|
||||
linkId: string;
|
||||
left: number;
|
||||
top: number;
|
||||
flipped: boolean;
|
||||
}
|
||||
|
||||
function clampToolbarCenter(centerX: number, toolbarWidth: number): number {
|
||||
if (toolbarWidth <= 0 || typeof window === "undefined") return centerX;
|
||||
|
||||
const minCenter = TOOLBAR_EDGE_MARGIN + toolbarWidth / 2;
|
||||
const maxCenter = window.innerWidth - TOOLBAR_EDGE_MARGIN - toolbarWidth / 2;
|
||||
|
||||
if (minCenter > maxCenter) return window.innerWidth / 2;
|
||||
return Math.min(Math.max(centerX, minCenter), maxCenter);
|
||||
}
|
||||
|
||||
function isRectOutsideViewport(rect: DOMRect): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
return (
|
||||
rect.bottom < 0 ||
|
||||
rect.top > window.innerHeight ||
|
||||
rect.right < 0 ||
|
||||
rect.left > window.innerWidth
|
||||
);
|
||||
}
|
||||
|
||||
const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
|
||||
({
|
||||
annotationLink,
|
||||
toolbarRef,
|
||||
left,
|
||||
top,
|
||||
scale,
|
||||
flipped,
|
||||
onNavigate,
|
||||
onDelete,
|
||||
@@ -188,20 +145,22 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const centerX =
|
||||
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
|
||||
scale;
|
||||
const topY = flipped
|
||||
? (annotationLink.rect.origin.y + annotationLink.rect.size.height) *
|
||||
scale +
|
||||
TOOLBAR_GAP
|
||||
: annotationLink.rect.origin.y * scale - TOOLBAR_HEIGHT - TOOLBAR_GAP;
|
||||
|
||||
const internal = isInternalLink(annotationLink);
|
||||
const label = getLinkLabel(annotationLink);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className={`pdf-link-toolbar${flipped ? " pdf-link-toolbar--below" : ""}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
maxWidth: `calc(100vw - ${TOOLBAR_EDGE_MARGIN * 2}px)`,
|
||||
zIndex: Z_INDEX_VIEWER_FLOATING_MENU,
|
||||
}}
|
||||
style={{ left: `${centerX}px`, top: `${topY}px` }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
@@ -229,7 +188,11 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
|
||||
e.stopPropagation();
|
||||
onNavigate(annotationLink);
|
||||
}}
|
||||
aria-label={internal ? `Go to ${label}` : "Open link"}
|
||||
aria-label={
|
||||
internal
|
||||
? `Go to page ${annotationLink.target?.type === "destination" ? annotationLink.target.destination.pageIndex + 1 : ""}`
|
||||
: "Open link"
|
||||
}
|
||||
title={label}
|
||||
>
|
||||
{internal ? <PageIcon /> : <ExternalLinkIcon />}
|
||||
@@ -260,11 +223,7 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
const documentState = useDocumentState(documentId);
|
||||
|
||||
const [hoveredLinkId, setHoveredLinkId] = useState<string | null>(null);
|
||||
const [toolbarPlacement, setToolbarPlacement] =
|
||||
useState<ToolbarPlacement | null>(null);
|
||||
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const linkElementRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
|
||||
const toolbarElementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Extract link annotations for this page from EmbedPDF annotation state
|
||||
const linkAnnotations = useMemo<PdfLinkAnnoObject[]>(() => {
|
||||
@@ -292,52 +251,6 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
|
||||
// EmbedPDF scale factor (annotation rects are in PDF points at scale 1)
|
||||
const scale = documentState?.scale ?? 1;
|
||||
// The portal position is measured from DOM, so this key realigns it when the page transform changes.
|
||||
const toolbarPositionKey = [
|
||||
scale,
|
||||
documentState?.document?.pages?.[pageIndex]?.rotation ?? 0,
|
||||
documentState?.rotation ?? 0,
|
||||
].join(":");
|
||||
|
||||
const updateToolbarPlacement = useCallback((linkId: string | null) => {
|
||||
if (!linkId) {
|
||||
setToolbarPlacement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkElement = linkElementRefs.current.get(linkId);
|
||||
if (!linkElement) {
|
||||
setHoveredLinkId(null);
|
||||
setToolbarPlacement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const rect = linkElement.getBoundingClientRect();
|
||||
if (isRectOutsideViewport(rect)) {
|
||||
setHoveredLinkId(null);
|
||||
setToolbarPlacement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbarWidth = toolbarElementRef.current?.offsetWidth ?? 0;
|
||||
const verticalSpace = TOOLBAR_HEIGHT + TOOLBAR_GAP + TOOLBAR_EDGE_MARGIN;
|
||||
const fitsAbove = rect.top >= verticalSpace;
|
||||
const fitsBelow = window.innerHeight - rect.bottom >= verticalSpace;
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const flipped = !fitsAbove && fitsBelow;
|
||||
const top = flipped
|
||||
? rect.bottom + TOOLBAR_GAP
|
||||
: Math.max(TOOLBAR_EDGE_MARGIN, rect.top - TOOLBAR_HEIGHT - TOOLBAR_GAP);
|
||||
|
||||
setToolbarPlacement({
|
||||
linkId,
|
||||
left: clampToolbarCenter(centerX, toolbarWidth),
|
||||
top,
|
||||
flipped,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearLeaveTimer = useCallback(() => {
|
||||
if (leaveTimerRef.current) {
|
||||
@@ -350,17 +263,15 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
clearLeaveTimer();
|
||||
leaveTimerRef.current = setTimeout(() => {
|
||||
setHoveredLinkId(null);
|
||||
setToolbarPlacement(null);
|
||||
}, TOOLBAR_LEAVE_DELAY);
|
||||
}, 120);
|
||||
}, [clearLeaveTimer]);
|
||||
|
||||
const handleLinkMouseEnter = useCallback(
|
||||
(id: string) => {
|
||||
clearLeaveTimer();
|
||||
setHoveredLinkId(id);
|
||||
updateToolbarPlacement(id);
|
||||
},
|
||||
[clearLeaveTimer, updateToolbarPlacement],
|
||||
[clearLeaveTimer],
|
||||
);
|
||||
|
||||
const handleLinkMouseLeave = useCallback(() => {
|
||||
@@ -379,7 +290,6 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
(annotationLink: PdfLinkAnnoObject) => {
|
||||
if (!annotationLink.target) {
|
||||
setHoveredLinkId(null);
|
||||
setToolbarPlacement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -419,7 +329,6 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
}
|
||||
|
||||
setHoveredLinkId(null);
|
||||
setToolbarPlacement(null);
|
||||
},
|
||||
[scroll],
|
||||
);
|
||||
@@ -427,103 +336,35 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
const handleDelete = useCallback(
|
||||
(annotationLink: PdfLinkAnnoObject) => {
|
||||
setHoveredLinkId(null);
|
||||
setToolbarPlacement(null);
|
||||
if (!scope) return;
|
||||
scope.deleteAnnotation(pageIndex, annotationLink.id);
|
||||
},
|
||||
[scope, pageIndex],
|
||||
);
|
||||
|
||||
useEffect(() => () => clearLeaveTimer(), [clearLeaveTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hoveredLinkId) {
|
||||
setToolbarPlacement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateToolbarPlacement(hoveredLinkId);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handleViewportChange = () => {
|
||||
updateToolbarPlacement(hoveredLinkId);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
};
|
||||
}, [
|
||||
hoveredLinkId,
|
||||
linkAnnotations,
|
||||
toolbarPositionKey,
|
||||
updateToolbarPlacement,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hoveredLinkId || toolbarPlacement?.linkId !== hoveredLinkId) return;
|
||||
updateToolbarPlacement(hoveredLinkId);
|
||||
}, [
|
||||
hoveredLinkId,
|
||||
toolbarPlacement?.linkId,
|
||||
toolbarPositionKey,
|
||||
updateToolbarPlacement,
|
||||
]);
|
||||
|
||||
const hoveredAnnotationLink = useMemo(
|
||||
() => linkAnnotations.find((link) => link.id === hoveredLinkId) ?? null,
|
||||
[linkAnnotations, hoveredLinkId],
|
||||
);
|
||||
|
||||
if (linkAnnotations.length === 0) return null;
|
||||
|
||||
const toolbarPortal =
|
||||
hoveredAnnotationLink &&
|
||||
toolbarPlacement?.linkId === hoveredAnnotationLink.id &&
|
||||
typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<LinkToolbar
|
||||
annotationLink={hoveredAnnotationLink}
|
||||
toolbarRef={toolbarElementRef}
|
||||
left={toolbarPlacement.left}
|
||||
top={toolbarPlacement.top}
|
||||
flipped={toolbarPlacement.flipped}
|
||||
onNavigate={handleNavigate}
|
||||
onDelete={handleDelete}
|
||||
onMouseEnter={handleToolbarMouseEnter}
|
||||
onMouseLeave={handleToolbarMouseLeave}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ pointerEvents: "none", zIndex: 10 }}
|
||||
>
|
||||
{linkAnnotations.map((annotationLink) => {
|
||||
const isHovered = hoveredLinkId === annotationLink.id;
|
||||
const left = annotationLink.rect.origin.x * scale;
|
||||
const top = annotationLink.rect.origin.y * scale;
|
||||
const width = annotationLink.rect.size.width * scale;
|
||||
const height = annotationLink.rect.size.height * scale;
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ pointerEvents: "none", zIndex: 10 }}
|
||||
>
|
||||
{linkAnnotations.map((annotationLink) => {
|
||||
const isHovered = hoveredLinkId === annotationLink.id;
|
||||
const left = annotationLink.rect.origin.x * scale;
|
||||
const top = annotationLink.rect.origin.y * scale;
|
||||
const width = annotationLink.rect.size.width * scale;
|
||||
const height = annotationLink.rect.size.height * scale;
|
||||
|
||||
return (
|
||||
// Flip toolbar below if link is near the top of the page
|
||||
const flipped =
|
||||
annotationLink.rect.origin.y * scale <
|
||||
TOOLBAR_HEIGHT + TOOLBAR_GAP + 4;
|
||||
|
||||
return (
|
||||
<React.Fragment key={annotationLink.id}>
|
||||
{/* Hit-area overlay */}
|
||||
<a
|
||||
key={annotationLink.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
linkElementRefs.current.set(annotationLink.id, node);
|
||||
} else {
|
||||
linkElementRefs.current.delete(annotationLink.id);
|
||||
}
|
||||
}}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -546,10 +387,22 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
|
||||
tabIndex={0}
|
||||
aria-label={getLinkLabel(annotationLink)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{toolbarPortal}
|
||||
</>
|
||||
|
||||
{/* Floating toolbar */}
|
||||
{isHovered && (
|
||||
<LinkToolbar
|
||||
annotationLink={annotationLink}
|
||||
scale={scale}
|
||||
flipped={flipped}
|
||||
onNavigate={handleNavigate}
|
||||
onDelete={handleDelete}
|
||||
onMouseEnter={handleToolbarMouseEnter}
|
||||
onMouseLeave={handleToolbarMouseLeave}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,6 +80,12 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
enableRedact: () => {
|
||||
redactionProvides?.enableRedact();
|
||||
},
|
||||
enableRedactSelection: () => {
|
||||
redactionProvides?.enableRedactSelection();
|
||||
},
|
||||
enableMarqueeRedact: () => {
|
||||
redactionProvides?.enableMarqueeRedact();
|
||||
},
|
||||
isRedactActive: () => {
|
||||
return redactionProvides?.isRedactActive() ?? false;
|
||||
},
|
||||
|
||||
@@ -17,6 +17,8 @@ import { RedactionMode } from "@embedpdf/plugin-redaction";
|
||||
export interface RedactionAPI {
|
||||
toggleRedact: () => void;
|
||||
enableRedact: () => void;
|
||||
enableRedactSelection: () => void;
|
||||
enableMarqueeRedact: () => void;
|
||||
isRedactActive: () => boolean;
|
||||
endRedact: () => void;
|
||||
// Common methods
|
||||
@@ -211,19 +213,19 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Legacy UI actions for backwards compatibility
|
||||
// In v2.5.0, both text selection and marquee use the same unified mode
|
||||
// These just activate the unified redact mode and set the active type for UI state
|
||||
// Activate a specific manual-redaction sub-mode. The plugin (v2.14.1) has
|
||||
// dedicated text-selection and marquee modes; the optimistic setActiveType is
|
||||
// reconciled by RedactionAPIBridge syncing the real state from EmbedPDF.
|
||||
const activateTextSelection = useCallback(() => {
|
||||
if (redactionApiRef.current) {
|
||||
redactionApiRef.current.enableRedact();
|
||||
redactionApiRef.current.enableRedactSelection();
|
||||
setActiveType("redactSelection" as RedactionMode);
|
||||
}
|
||||
}, [setActiveType]);
|
||||
|
||||
const activateMarquee = useCallback(() => {
|
||||
if (redactionApiRef.current) {
|
||||
redactionApiRef.current.enableRedact();
|
||||
redactionApiRef.current.enableMarqueeRedact();
|
||||
setActiveType("marqueeRedact" as RedactionMode);
|
||||
}
|
||||
}, [setActiveType]);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Core stub for SaasBillingContext.
|
||||
* Returns null in web/core builds — desktop layer shadows this with the real implementation.
|
||||
* See: frontend/src/desktop/contexts/SaasBillingContext.tsx
|
||||
*/
|
||||
export const useSaaSBilling = (): null => null;
|
||||
|
||||
export function SaasBillingProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* No-op stub kept only to satisfy useToolOperation's @app/hooks/useCreditCheck
|
||||
* import. There is no pre-flight credit/usage check anymore — PAYG is enforced
|
||||
* server-side (the BE returns 402 FEATURE_DEGRADED / PAYG_LIMIT_REACHED, which
|
||||
* paygErrorInterceptor turns into the usage-limit modal). Always allows.
|
||||
* Core stub for credit checking before cloud operations
|
||||
* Desktop layer shadows this with the real implementation
|
||||
* In web builds, always allows the operation (no credit system)
|
||||
*/
|
||||
export function useCreditCheck(_operationType?: string, _endpoint?: string) {
|
||||
return {
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
--icon-sign-color: #ffffff;
|
||||
--icon-automate-bg: #a576e3;
|
||||
--icon-automate-color: #ffffff;
|
||||
--icon-watchedFolders-bg: #f59e0b;
|
||||
--icon-watchedFolders-color: #ffffff;
|
||||
--icon-files-bg: #d3e7f7;
|
||||
--icon-files-color: #0a8bff;
|
||||
--icon-activity-bg: #d3e7f7;
|
||||
@@ -535,6 +537,8 @@
|
||||
--icon-sign-color: #eaeaea;
|
||||
--icon-automate-bg: #4b525a;
|
||||
--icon-automate-color: #eaeaea;
|
||||
--icon-watchedFolders-bg: #4b525a;
|
||||
--icon-watchedFolders-color: #eaeaea;
|
||||
--icon-files-bg: #4b525a;
|
||||
--icon-files-color: #eaeaea;
|
||||
--icon-activity-bg: #4b525a;
|
||||
@@ -796,11 +800,10 @@
|
||||
}
|
||||
/* ── PDF Link Overlay (viewer) ── */
|
||||
:root {
|
||||
--link-hover-bg: rgba(10, 139, 255, 0.1);
|
||||
--link-hover-border: rgba(10, 139, 255, 0.32);
|
||||
--link-hover-bg: rgba(10, 139, 255, 0.04);
|
||||
--link-hover-border: rgba(10, 139, 255, 0.15);
|
||||
--link-hover-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
|
||||
0 1px 4px rgba(10, 139, 255, 0.12);
|
||||
0 0 0 0.5px rgba(10, 139, 255, 0.05), 0 1px 3px rgba(10, 139, 255, 0.04);
|
||||
--link-focus-ring: rgba(10, 139, 255, 0.25);
|
||||
--link-toolbar-bg: rgba(15, 23, 42, 0.88);
|
||||
--link-toolbar-border: rgba(255, 255, 255, 0.1);
|
||||
@@ -810,11 +813,10 @@
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
--link-hover-bg: rgba(59, 170, 255, 0.14);
|
||||
--link-hover-border: rgba(59, 170, 255, 0.38);
|
||||
--link-hover-bg: rgba(10, 139, 255, 0.06);
|
||||
--link-hover-border: rgba(59, 170, 255, 0.2);
|
||||
--link-hover-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.14),
|
||||
0 1px 4px rgba(10, 139, 255, 0.18);
|
||||
0 0 0 0.5px rgba(59, 170, 255, 0.1), 0 1px 3px rgba(10, 139, 255, 0.08);
|
||||
--link-focus-ring: rgba(59, 170, 255, 0.3);
|
||||
--link-toolbar-bg: rgba(15, 23, 42, 0.92);
|
||||
--link-toolbar-border: rgba(255, 255, 255, 0.08);
|
||||
@@ -845,7 +847,7 @@
|
||||
.pdf-link-overlay:hover,
|
||||
.pdf-link-overlay--active {
|
||||
background: var(--link-hover-bg);
|
||||
border-color: var(--link-hover-border);
|
||||
border-bottom: 2px solid var(--color-primary-500);
|
||||
box-shadow: var(--link-hover-shadow);
|
||||
}
|
||||
|
||||
@@ -887,7 +889,6 @@
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
@@ -933,15 +934,11 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
line-height: 1;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.pdf-link-toolbar-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.25;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
@@ -988,10 +985,6 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.pdf-link-toolbar-btn--go {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdf-link-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
|
||||
@@ -6,12 +6,6 @@ export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
|
||||
export const Z_ANALYTICS_MODAL = 1301;
|
||||
// Config/Settings modal - should appear above analytics modal when navigating from onboarding
|
||||
export const Z_INDEX_CONFIG_MODAL = 1400;
|
||||
// Modal layered directly over the settings/config modal (e.g. the Stripe
|
||||
// checkout modal). Consumed by the shared cloud/ checkout component, so it
|
||||
// lives in the core base both the saas and cloud cascades resolve.
|
||||
// Must be strictly ABOVE the config modal (1400); 1450 is taken by the cookie
|
||||
// preferences modal, so sit above the whole settings cluster (below 2000).
|
||||
export const Z_INDEX_OVER_SETTINGS_MODAL = 1500;
|
||||
|
||||
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
||||
|
||||
@@ -40,9 +34,6 @@ export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
|
||||
// Sign-in modal — must appear above all app UI including config and analytics modals
|
||||
export const Z_INDEX_SIGN_IN_MODAL = 9000;
|
||||
|
||||
// Floating viewer menus rendered through document.body portals.
|
||||
export const Z_INDEX_VIEWER_FLOATING_MENU = 10000;
|
||||
|
||||
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
|
||||
export const Z_INDEX_TOAST = 10001;
|
||||
|
||||
|
||||
@@ -86,11 +86,7 @@ test.describe("1. Authentication and Login", () => {
|
||||
});
|
||||
|
||||
test.describe("1.5 Login Page - Session Expiry and Redirect", () => {
|
||||
// FIXME: flaky. After forcing /api/v1/auth/refresh to 401 and reloading, the
|
||||
// SPA intermittently settles on /merge instead of redirecting to /login (the
|
||||
// auth bootstrap races re-authentication against the redirect). Re-enable once
|
||||
// the auth-refresh redirect race is fixed. See e2e-live run 27675862403.
|
||||
test.skip("should redirect back to intended page after re-login", async ({
|
||||
test("should redirect back to intended page after re-login", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Starting state: User is logged in and on a tool page
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { authService } from "@app/services/authService";
|
||||
import type { AppSession } from "@cloud/auth/session";
|
||||
|
||||
export type { AppSession };
|
||||
|
||||
/**
|
||||
* Desktop (Tauri) implementation of the @app/auth/session seam.
|
||||
*
|
||||
* Delegates to authService, which manages the JWT in the Tauri secure store
|
||||
* (with a localStorage fallback) and validates/caches it. Behaviour of
|
||||
* authService is unchanged — this only exposes its existing accessor.
|
||||
*/
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return authService.getAuthToken();
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ───────────────────────────────────────────────────────────────────
|
||||
const { subscribeToAuthMock, getCurrentModeMock, subscribeToModeChangesMock } =
|
||||
vi.hoisted(() => ({
|
||||
subscribeToAuthMock: vi.fn(),
|
||||
getCurrentModeMock: vi.fn(),
|
||||
subscribeToModeChangesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/authService", () => ({
|
||||
authService: { subscribeToAuth: subscribeToAuthMock },
|
||||
}));
|
||||
vi.mock("@app/services/connectionModeService", () => ({
|
||||
connectionModeService: {
|
||||
getCurrentMode: getCurrentModeMock,
|
||||
subscribeToModeChanges: subscribeToModeChangesMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { useTeamAuth } from "@app/auth/teamSession";
|
||||
|
||||
/** Wire authService.subscribeToAuth to immediately report `status`. */
|
||||
function withAuthStatus(status: "authenticated" | "unauthenticated") {
|
||||
subscribeToAuthMock.mockImplementation(
|
||||
(listener: (s: string, u: unknown) => void) => {
|
||||
listener(status, null);
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("useTeamAuth (desktop)", () => {
|
||||
beforeEach(() => {
|
||||
subscribeToAuthMock.mockReset();
|
||||
getCurrentModeMock.mockReset();
|
||||
subscribeToModeChangesMock.mockReset();
|
||||
subscribeToModeChangesMock.mockReturnValue(() => {});
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("withholds team access until the SaaS mode is known (pessimistic default)", () => {
|
||||
withAuthStatus("authenticated");
|
||||
// getCurrentMode never resolves within this synchronous render.
|
||||
getCurrentModeMock.mockReturnValue(new Promise<never>(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useTeamAuth());
|
||||
expect(result.current.canUseTeams).toBe(false);
|
||||
});
|
||||
|
||||
it("grants team access only when authenticated AND in SaaS mode", async () => {
|
||||
withAuthStatus("authenticated");
|
||||
getCurrentModeMock.mockResolvedValue("saas");
|
||||
|
||||
const { result } = renderHook(() => useTeamAuth());
|
||||
await waitFor(() => expect(result.current.canUseTeams).toBe(true));
|
||||
});
|
||||
|
||||
it("denies team access in self-hosted mode even when authenticated", async () => {
|
||||
withAuthStatus("authenticated");
|
||||
getCurrentModeMock.mockResolvedValue("selfhosted");
|
||||
|
||||
const { result } = renderHook(() => useTeamAuth());
|
||||
// Flush the resolved mode promise (+ its state update) inside act, then
|
||||
// assert canUseTeams stayed false.
|
||||
await act(async () => {});
|
||||
expect(result.current.canUseTeams).toBe(false);
|
||||
});
|
||||
|
||||
it("denies team access when in SaaS mode but unauthenticated", async () => {
|
||||
withAuthStatus("unauthenticated");
|
||||
getCurrentModeMock.mockResolvedValue("saas");
|
||||
|
||||
const { result } = renderHook(() => useTeamAuth());
|
||||
await act(async () => {});
|
||||
expect(result.current.canUseTeams).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Desktop (Tauri) implementation of the @app/auth/teamSession seam.
|
||||
*
|
||||
* Wires the cloud {@link SaaSTeamContext} to the desktop auth surface. Desktop
|
||||
* keeps its JWT in the Tauri secure store via authService, so team access is
|
||||
* gated on a live "authenticated" status from authService rather than a
|
||||
* Supabase session.
|
||||
*
|
||||
* Teams are a cloud-only surface: the endpoints (/api/v1/team/**) live on the
|
||||
* SaaS backend, not the local bundled backend or a self-hosted server. The
|
||||
* SaaSTeamProvider is mounted unconditionally in AppProviders, so canUseTeams
|
||||
* is the ONLY thing stopping its mount effect from fetching teams. It must
|
||||
* therefore also require SaaS connection mode — otherwise an authenticated user
|
||||
* in self-hosted or local ("disconnected") mode triggers /api/v1/team/my +
|
||||
* /api/v1/team/invitations/pending against a backend that 404s them.
|
||||
*
|
||||
* The SaaS-mode flag starts pessimistically false (NOT useSaaSMode()'s
|
||||
* optimistic true): authService and connectionModeService resolve
|
||||
* independently, and an optimistic default would let one team fetch slip
|
||||
* through on cold start before the mode is known. Desktop has no credit/session
|
||||
* refreshers, so the post-membership refresh is a no-op.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
|
||||
import type { TeamAuth } from "@cloud/auth/teamSession";
|
||||
|
||||
export type { TeamAuth } from "@cloud/auth/teamSession";
|
||||
|
||||
export function useTeamAuth(): TeamAuth {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
// Pessimistic SaaS-mode check (starts false) so no team fetch slips through on
|
||||
// cold start before the mode resolves. Shared with the Policies gate.
|
||||
const isSaaSMode = useConfirmedSaaSMode();
|
||||
|
||||
useEffect(() => {
|
||||
// subscribeToAuth immediately notifies the listener of the current state,
|
||||
// so no separate initial fetch is needed.
|
||||
return authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canUseTeams: isAuthenticated && isSaaSMode,
|
||||
refreshAfterMembershipChange: async () => {},
|
||||
};
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
|
||||
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
|
||||
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
|
||||
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal";
|
||||
import { DesktopSaasOnboardingBootstrap } from "@app/components/DesktopSaasOnboardingBootstrap";
|
||||
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||
import { SignInModal } from "@app/components/SignInModal";
|
||||
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
|
||||
import { ToolActionsContext } from "@app/contexts/ToolActionsContext";
|
||||
@@ -24,6 +22,9 @@ import { endpointAvailabilityService } from "@app/services/endpointAvailabilityS
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
|
||||
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
|
||||
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
|
||||
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
|
||||
import UpdateModal from "@core/components/shared/UpdateModal";
|
||||
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
|
||||
|
||||
@@ -337,24 +338,21 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<SaaSTeamProvider key={appKey}>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
{children}
|
||||
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
|
||||
<DesktopOnboardingModal />
|
||||
{/* SaaS product onboarding (cloud flow, minus the desktop-download slide),
|
||||
shown once after a SaaS sign-in. Mirrors saas's OnboardingBootstrap. */}
|
||||
<DesktopSaasOnboardingBootstrap connectionMode={connectionMode} />
|
||||
{/* Always-mounted host for the PAYG usage-limit modals (free-limit /
|
||||
spend-cap). Resolves to the cloud implementation via @app; listens
|
||||
for both the imperative open events (direct-call 402s) and the
|
||||
usageLimitBridge event (server-side policy/AI run 402s). */}
|
||||
<UsageLimitModalHost />
|
||||
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
||||
<SignInModal />
|
||||
{/* Desktop auto-update popup */}
|
||||
{updatePopupModal}
|
||||
<SaasBillingProvider>
|
||||
<SaaSCheckoutProvider>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
<CreditModalBootstrap />
|
||||
{children}
|
||||
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
|
||||
<DesktopOnboardingModal />
|
||||
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
||||
<SignInModal />
|
||||
{/* Desktop auto-update popup */}
|
||||
{updatePopupModal}
|
||||
</SaaSCheckoutProvider>
|
||||
</SaasBillingProvider>
|
||||
</SaaSTeamProvider>
|
||||
</ToolActionsContext.Provider>
|
||||
</ProprietaryAppProviders>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal";
|
||||
|
||||
/**
|
||||
* Desktop bootstrap for the SaaS product onboarding.
|
||||
*
|
||||
* Mirrors saas's {@code OnboardingBootstrap}: once a SaaS user has signed in we
|
||||
* show the shared cloud {@link SaasOnboardingModal} (free-editor pitch → usage
|
||||
* meter → team), reusing the exact same flow as the web app. The closing
|
||||
* "download desktop" slide is dropped via {@code hideDesktopInstall} — this IS
|
||||
* the desktop app, so pitching its own download makes no sense.
|
||||
*
|
||||
* Differences from the saas bootstrap:
|
||||
* - The desktop {@code useAuth} (proprietary) exposes no pro/wallet fields; the
|
||||
* cloud flow reads the live wallet itself to decide which slides to show, so
|
||||
* we just wait for a non-anonymous signed-in user.
|
||||
* - Gated on {@code connectionMode === "saas"} so it never fires in local or
|
||||
* self-hosted mode (where there is no SaaS wallet/team to onboard).
|
||||
*
|
||||
* Shown once per device, gated by localStorage (same key the saas web flow uses
|
||||
* so a user who onboarded on the web isn't re-onboarded — they are independent
|
||||
* stores, but the key/intent is shared).
|
||||
*/
|
||||
const STORAGE_KEY = "saas_onboarding_seen";
|
||||
|
||||
interface DesktopSaasOnboardingBootstrapProps {
|
||||
connectionMode: "saas" | "selfhosted" | "local" | null;
|
||||
}
|
||||
|
||||
export function DesktopSaasOnboardingBootstrap({
|
||||
connectionMode,
|
||||
}: DesktopSaasOnboardingBootstrapProps) {
|
||||
const { user, loading } = useAuth();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const isSignedInSaasUser =
|
||||
connectionMode === "saas" &&
|
||||
!loading &&
|
||||
!!user &&
|
||||
user.is_anonymous !== true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedInSaasUser) return;
|
||||
let seen = false;
|
||||
try {
|
||||
seen = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
// localStorage unavailable — fail open and show onboarding.
|
||||
}
|
||||
if (!seen) {
|
||||
setShowModal(true);
|
||||
}
|
||||
}, [isSignedInSaasUser]);
|
||||
|
||||
const handleClose = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
} catch {
|
||||
// localStorage unavailable — best-effort; the in-memory flag still hides it.
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
if (!showModal) return null;
|
||||
|
||||
return (
|
||||
<SaasOnboardingModal
|
||||
opened={showModal}
|
||||
onClose={handleClose}
|
||||
hideDesktopInstall
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Desktop shadow of the proprietary Policies right-rail.
|
||||
*
|
||||
* Re-exports the proprietary module unchanged EXCEPT `usePoliciesEnabled`, which
|
||||
* additionally requires an active SaaS connection. Policy runs execute + bill via
|
||||
* the cloud (POST /api/v1/policies/.../run hits the SaaS backend), so on desktop
|
||||
* the feature must be hidden in local ("disconnected") and self-hosted modes —
|
||||
* otherwise the panel + auto-run controller would fire policy runs against a
|
||||
* backend that doesn't serve them. On web the build flavor already gates it.
|
||||
*
|
||||
* `usePoliciesEnabled` is the single gate RightSidebar uses for BOTH the rail
|
||||
* section and mounting PolicyAutoRunController, so this one override covers both.
|
||||
*/
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
|
||||
|
||||
export * from "@proprietary/components/policies/PoliciesSidebar";
|
||||
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
// Pessimistic SaaS-mode check (starts false): this gate also controls whether
|
||||
// PolicyAutoRunController mounts, and that fires GET /api/v1/policies on mount.
|
||||
// useSaaSMode()'s optimistic-true default would leak that request against the
|
||||
// local/self-hosted backend on cold start before the mode resolves.
|
||||
return POLICIES_ENABLED && useConfirmedSaaSMode();
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Text, Stack } from "@mantine/core";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { BILLING_CONFIG } from "@app/config/billing";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
|
||||
|
||||
/**
|
||||
* Desktop credit counter displayed in QuickAccessBar footer
|
||||
* Shows when user is in SaaS mode with low credits (<20)
|
||||
*/
|
||||
|
||||
interface QuickAccessBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuickAccessBarFooterExtensions({
|
||||
className,
|
||||
}: QuickAccessBarFooterExtensionsProps) {
|
||||
const { creditBalance, loading, isManagedTeamMember } = useSaaSBilling();
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Check connection mode and authentication status
|
||||
useEffect(() => {
|
||||
const checkMode = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkMode();
|
||||
|
||||
// Subscribe to mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(checkMode);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Don't show credit counter if:
|
||||
// - Not in SaaS mode
|
||||
// - Not authenticated
|
||||
// - Still loading billing data
|
||||
// - User is a managed team member (unlimited credits)
|
||||
// - Credits >= 20 (only show when low)
|
||||
if (
|
||||
!isSaasMode ||
|
||||
!isAuthenticated ||
|
||||
loading ||
|
||||
isManagedTeamMember ||
|
||||
creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Dispatch low credits event to open upgrade modal
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CREDIT_EVENTS.EXHAUSTED, {
|
||||
detail: { source: "quickAccessBar" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
style={{ padding: "0.5rem", cursor: "pointer" }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{creditBalance} {creditBalance === 1 ? "credit" : "credits"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ textDecoration: "underline" }}>
|
||||
Upgrade
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function CloudBadge({ className }: CloudBadgeProps) {
|
||||
<Tooltip
|
||||
label={t(
|
||||
"cloudBadge.tooltip",
|
||||
"Runs in the cloud (included, no extra charge)",
|
||||
"This operation will use your cloud credits",
|
||||
)}
|
||||
position="top"
|
||||
withArrow
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
export function TeamInvitationBanner() {
|
||||
const { t } = useTranslation();
|
||||
const { receivedInvitations, acceptInvitation, rejectInvitation } =
|
||||
useSaaSTeam();
|
||||
const { refreshBilling } = useSaaSBilling();
|
||||
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [connectionMode, setConnectionMode] = useState<string | null>(null);
|
||||
|
||||
// Load connection mode on mount
|
||||
useEffect(() => {
|
||||
connectionModeService
|
||||
.getCurrentMode()
|
||||
.then((mode) => setConnectionMode(mode));
|
||||
}, []);
|
||||
|
||||
// Accept invitation handler
|
||||
const handleAccept = async () => {
|
||||
const invitation = receivedInvitations[0];
|
||||
if (!invitation) return;
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
await acceptInvitation(invitation.invitationToken);
|
||||
console.log(
|
||||
"[TeamInvitationBanner] Invitation accepted successfully:",
|
||||
invitation.teamName,
|
||||
);
|
||||
|
||||
// Wait briefly for backend to process team membership update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Refresh billing after joining team (tier may have changed)
|
||||
console.log(
|
||||
"[TeamInvitationBanner] Refreshing billing after team join...",
|
||||
);
|
||||
await refreshBilling();
|
||||
|
||||
setDismissed(true);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[TeamInvitationBanner] Failed to accept invitation:",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reject invitation handler
|
||||
const handleReject = async () => {
|
||||
const invitation = receivedInvitations[0];
|
||||
if (!invitation) return;
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
await rejectInvitation(invitation.invitationToken);
|
||||
console.log("[TeamInvitationBanner] Invitation rejected");
|
||||
setDismissed(true);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[TeamInvitationBanner] Failed to reject invitation:",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Visibility logic
|
||||
const shouldShow =
|
||||
connectionMode === "saas" && !dismissed && receivedInvitations.length > 0;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const invitation = receivedInvitations[0]; // Show first invitation
|
||||
|
||||
const message = (
|
||||
<Text
|
||||
component="span"
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ color: "rgba(255, 255, 255, 0.95)" }}
|
||||
>
|
||||
<strong>{invitation.inviterEmail}</strong>{" "}
|
||||
{t("team.invitationBanner.message", "has invited you to join")}{" "}
|
||||
<strong>{invitation.teamName}</strong>
|
||||
</Text>
|
||||
);
|
||||
|
||||
const actionButtons = (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="white"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={handleAccept}
|
||||
loading={processing}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="check"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
style={{ color: "var(--mantine-color-dark-9)" }}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
label: {
|
||||
color: "var(--mantine-color-dark-9)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("team.invitationBanner.acceptButton", "Accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={handleReject}
|
||||
loading={processing}
|
||||
style={{ color: "rgba(255, 255, 255, 0.7)" }}
|
||||
>
|
||||
{t("team.invitationBanner.rejectButton", "Decline")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
icon="mail"
|
||||
message={
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{message}
|
||||
{actionButtons}
|
||||
</Group>
|
||||
}
|
||||
show={shouldShow}
|
||||
dismissible={false}
|
||||
background="var(--mantine-color-dark-7)"
|
||||
borderColor="var(--mantine-color-dark-5)"
|
||||
textColor="rgba(255, 255, 255, 0.95)"
|
||||
iconColor="rgba(255, 255, 255, 0.95)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Button, Text, Alert, Loader, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { saasBillingService } from "@app/services/saasBillingService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import OpenInBrowserIcon from "@mui/icons-material/OpenInBrowser";
|
||||
|
||||
type CheckoutState = {
|
||||
status: "idle" | "loading" | "opened" | "refreshing" | "error";
|
||||
error?: string;
|
||||
sessionPlanId?: string;
|
||||
};
|
||||
|
||||
interface SaaSStripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planId: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const SaaSStripeCheckout: React.FC<SaaSStripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planId,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: "idle" });
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
if (!planId) {
|
||||
setState({ status: "error", error: "No plan selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState({ status: "loading" });
|
||||
|
||||
// Map UI plan IDs to Stripe plan IDs
|
||||
const stripePlanId = planId === "team" ? "pro" : planId;
|
||||
|
||||
// Open checkout in browser (returns void, opens browser window)
|
||||
await saasBillingService.openCheckout(
|
||||
stripePlanId as "pro",
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
setState({
|
||||
status: "opened",
|
||||
sessionPlanId: planId,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create checkout session";
|
||||
console.error("[SaaSStripeCheckout] Error creating checkout:", err);
|
||||
setState({
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshClick = async () => {
|
||||
console.log("[SaaSStripeCheckout] User requested refresh after checkout");
|
||||
setState({ ...state, status: "refreshing" });
|
||||
|
||||
// Give Stripe webhooks a moment to process (2-3 seconds)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Trigger the refresh
|
||||
if (onSuccess) {
|
||||
await onSuccess();
|
||||
}
|
||||
|
||||
// Close modal after refresh
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state to idle to clean up the session
|
||||
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Initialize checkout when modal opens or plan changes
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
// Check if we need a new session (first time or plan changed)
|
||||
const needsNewSession =
|
||||
state.status === "idle" ||
|
||||
!state.sessionPlanId ||
|
||||
state.sessionPlanId !== planId;
|
||||
|
||||
if (needsNewSession) {
|
||||
console.log(
|
||||
"[SaaSStripeCheckout] Opening checkout in browser for plan:",
|
||||
planId,
|
||||
);
|
||||
createCheckoutSession();
|
||||
}
|
||||
} else if (!opened) {
|
||||
// Clean up state when modal closes
|
||||
setState({ status: "idle", error: undefined, sessionPlanId: undefined });
|
||||
}
|
||||
}, [opened, planId]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t("payment.preparing", "Preparing your checkout...")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "opened":
|
||||
return (
|
||||
<Alert
|
||||
color="blue"
|
||||
title={t("payment.checkoutOpened", "Checkout Opened in Browser")}
|
||||
icon={<OpenInBrowserIcon />}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"payment.checkoutInstructions",
|
||||
"Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={handleRefreshClick}
|
||||
fullWidth
|
||||
>
|
||||
{t(
|
||||
"payment.refreshBilling",
|
||||
"I've Completed Payment - Refresh Billing",
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={handleClose} fullWidth>
|
||||
{t("payment.closeLater", "I'll Do This Later")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Alert color="red" title={t("payment.error", "Payment Error")}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{state.error}</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanName = () => {
|
||||
if (planId === "team") return t("plan.team.name", "Team");
|
||||
if (planId === "enterprise") return t("plan.enterprise.name", "Enterprise");
|
||||
return t("plan.free.name", "Free");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("payment.upgradeTitle", "Upgrade to {{planName}}", {
|
||||
planName: getPlanName(),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
size="md"
|
||||
centered
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useConfigNavSections as useProprietaryConfigNavSections } from "@proprietary/components/shared/config/configNavSections";
|
||||
import {
|
||||
useConfigNavSections as useProprietaryConfigNavSections,
|
||||
createConfigNavSections as createProprietaryConfigNavSections,
|
||||
} from "@proprietary/components/shared/config/configNavSections";
|
||||
import { ConfigNavSection } from "@core/components/shared/config/configNavSections";
|
||||
import { ConnectionSettings } from "@app/components/ConnectionSettings";
|
||||
import {
|
||||
createCloudPlanNavItem,
|
||||
createCloudTeamNavItem,
|
||||
} from "@app/components/shared/config/cloudConfigNavSections";
|
||||
import { SaasPlanSection } from "@app/components/shared/config/configSections/SaasPlanSection";
|
||||
import { SaaSTeamsSection } from "@app/components/shared/config/configSections/SaaSTeamsSection";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { authService } from "@app/services/authService";
|
||||
|
||||
@@ -99,18 +100,29 @@ export const useConfigNavSections = (
|
||||
// Connection Mode always sits immediately after Preferences
|
||||
result.push(connectionModeSection);
|
||||
|
||||
// Plan & Billing and Team sections only when authenticated in SaaS mode.
|
||||
// These are the SHARED cloud sections — the wallet-driven PAYG Plan
|
||||
// (dashboard + spend cap) and the team management UI — so a desktop SaaS
|
||||
// user sees the same Plan / billing / team experience as the web app.
|
||||
// Plan & Billing and Team sections only when authenticated in SaaS mode
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
result.push({
|
||||
title: t("settings.planBilling.title", "Plan & Billing"),
|
||||
items: [createCloudPlanNavItem(t)],
|
||||
items: [
|
||||
{
|
||||
key: "planBilling",
|
||||
label: t("settings.planBilling.title", "Plan & Billing"),
|
||||
icon: "credit-card",
|
||||
component: <SaasPlanSection />,
|
||||
},
|
||||
],
|
||||
});
|
||||
result.push({
|
||||
title: t("settings.team.title", "Team"),
|
||||
items: [createCloudTeamNavItem(t)],
|
||||
items: [
|
||||
{
|
||||
key: "teams",
|
||||
label: t("settings.team.title", "Team"),
|
||||
icon: "groups-rounded",
|
||||
component: <SaaSTeamsSection />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,3 +146,52 @@ export const useConfigNavSections = (
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecated: Use useConfigNavSections hook instead
|
||||
* Desktop extension of createConfigNavSections that adds connection settings
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false,
|
||||
): ConfigNavSection[] => {
|
||||
console.warn(
|
||||
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
|
||||
);
|
||||
|
||||
// Get the proprietary sections (includes core Preferences + admin sections)
|
||||
const sections = createProprietaryConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
);
|
||||
|
||||
// Add Connection section at the beginning (after Preferences)
|
||||
sections.splice(1, 0, {
|
||||
title: "Connection",
|
||||
items: [
|
||||
{
|
||||
key: "connectionMode",
|
||||
label: "Connection Mode",
|
||||
icon: "desktop-cloud-rounded",
|
||||
component: <ConnectionSettings />,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Add Plan & Billing section (after Connection Mode)
|
||||
sections.splice(2, 0, {
|
||||
title: "Plan & Billing",
|
||||
items: [
|
||||
{
|
||||
key: "planBilling",
|
||||
label: "Plan & Billing",
|
||||
icon: "credit-card",
|
||||
component: <SaasPlanSection />,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
TextInput,
|
||||
Group,
|
||||
Text,
|
||||
Stack,
|
||||
Alert,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
/**
|
||||
* Desktop SaaS Teams Section
|
||||
* Allows team management for users connected to SaaS backend
|
||||
* CRITICAL: Only shown when in SaaS mode (enforced by navigation)
|
||||
*/
|
||||
export function SaaSTeamsSection() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
currentTeam,
|
||||
teamMembers,
|
||||
teamInvitations,
|
||||
isTeamLeader,
|
||||
isPersonalTeam,
|
||||
inviteUser,
|
||||
cancelInvitation,
|
||||
removeMember,
|
||||
leaveTeam,
|
||||
refreshTeams,
|
||||
} = useSaaSTeam();
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Team rename state
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [newTeamName, setNewTeamName] = useState("");
|
||||
const [renamingTeam, setRenamingTeam] = useState(false);
|
||||
|
||||
// Refresh team data on mount and every 10 seconds
|
||||
useEffect(() => {
|
||||
// Refresh immediately on mount
|
||||
refreshTeams();
|
||||
|
||||
// Then refresh every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
refreshTeams();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []); // Only run on mount/unmount
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim()) return;
|
||||
|
||||
setInviting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
await inviteUser(inviteEmail);
|
||||
setSuccess(
|
||||
t("team.inviteSent", "Invitation sent to {{email}}", {
|
||||
email: inviteEmail,
|
||||
}),
|
||||
);
|
||||
setInviteEmail("");
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error.response?.data?.error ||
|
||||
t("team.inviteError", "Failed to send invitation"),
|
||||
);
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (memberId: number, memberEmail: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("team.confirmRemove", "Remove {{email}} from the team?", {
|
||||
email: memberEmail,
|
||||
}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await removeMember(memberId);
|
||||
setSuccess(t("team.memberRemoved", "Member removed successfully"));
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error.response?.data?.error ||
|
||||
t("team.removeError", "Failed to remove member"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelInvitation = async (
|
||||
invitationId: number,
|
||||
email: string,
|
||||
) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("team.confirmCancelInvite", "Cancel invitation for {{email}}?", {
|
||||
email,
|
||||
}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await cancelInvitation(invitationId);
|
||||
setSuccess(
|
||||
t("team.inviteCancelled", "Invitation for {{email}} cancelled", {
|
||||
email,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error.response?.data?.error ||
|
||||
t("team.cancelInviteError", "Failed to cancel invitation"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRename = () => {
|
||||
if (currentTeam) {
|
||||
setNewTeamName(currentTeam.name);
|
||||
setIsEditingName(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setIsEditingName(false);
|
||||
setNewTeamName("");
|
||||
};
|
||||
|
||||
const handleRenameSubmit = async () => {
|
||||
if (!currentTeam || !newTeamName.trim()) return;
|
||||
|
||||
setRenamingTeam(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, {
|
||||
newName: newTeamName.trim(),
|
||||
});
|
||||
|
||||
setSuccess(t("team.renameSuccess", "Team renamed successfully"));
|
||||
setIsEditingName(false);
|
||||
await refreshTeams();
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
setError(
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("team.renameError", "Failed to rename team"),
|
||||
);
|
||||
} finally {
|
||||
setRenamingTeam(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveTeam = async () => {
|
||||
if (!currentTeam || isPersonalTeam) return;
|
||||
|
||||
const confirmMessage = isTeamLeader
|
||||
? t(
|
||||
"team.confirmLeaveLeader",
|
||||
'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.',
|
||||
{ name: currentTeam.name },
|
||||
)
|
||||
: t("team.confirmLeave", 'Are you sure you want to leave "{{name}}"?', {
|
||||
name: currentTeam.name,
|
||||
});
|
||||
|
||||
if (!window.confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
await leaveTeam();
|
||||
setSuccess(t("team.leaveSuccess", "Successfully left team"));
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
setError(
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("team.leaveError", "Failed to leave team"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentTeam) {
|
||||
return (
|
||||
<Alert color="gray">
|
||||
<Text>{t("team.loading", "Loading team information...")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
{isEditingName ? (
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder={t("team.namePlaceholder", "Team name")}
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRenameSubmit();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={handleRenameSubmit}
|
||||
loading={renamingTeam}
|
||||
disabled={!newTeamName.trim()}
|
||||
>
|
||||
<LocalIcon icon="check" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleCancelRename}
|
||||
disabled={renamingTeam}
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{currentTeam.name}
|
||||
</Text>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleStartRename}
|
||||
aria-label={t("team.editName", "Edit team name")}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{isTeamLeader && (
|
||||
<Badge color="blue">{t("team.leader", "LEADER")}</Badge>
|
||||
)}
|
||||
{isPersonalTeam && (
|
||||
<Badge color="gray" variant="light" size="xs">
|
||||
{t("team.personal", "Personal")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{!isEditingName && !isPersonalTeam && (
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t("team.memberCount", "{{count}} team members", {
|
||||
count: currentTeam.seatsUsed,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleLeaveTeam}
|
||||
leftSection={
|
||||
<LocalIcon icon="logout" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("team.leaveButton", "Leave Team")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
<Alert color="red" onClose={() => setError(null)} withCloseButton>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert color="green" onClose={() => setSuccess(null)} withCloseButton>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Invite Members */}
|
||||
{isTeamLeader && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">
|
||||
{t("team.invite.title", "Invite Team Member")}
|
||||
</Text>
|
||||
<form onSubmit={handleInvite}>
|
||||
<Group>
|
||||
<TextInput
|
||||
type="email"
|
||||
placeholder={t("team.invite.placeholder", "email@example.com")}
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
required
|
||||
error={
|
||||
inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
|
||||
? t("team.invite.invalidEmail", "Invalid email format")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={inviting}
|
||||
disabled={
|
||||
!inviteEmail.trim() ||
|
||||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
|
||||
}
|
||||
>
|
||||
{t("team.invite.sendButton", "Send Invite")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Members Table */}
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">
|
||||
{t("team.members.title", "Team Members")}
|
||||
</Text>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr
|
||||
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
|
||||
>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{t("team.members.nameColumn", "Name")}
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{t("team.members.emailColumn", "Email")}
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{t("team.members.roleColumn", "Role")}
|
||||
</Table.Th>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
)}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teamMembers.length === 0 && teamInvitations.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t("team.members.empty", "No team members yet.")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
<>
|
||||
{/* Active Members */}
|
||||
{teamMembers.map((member) => (
|
||||
<Table.Tr key={`member-${member.id}`}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{member.username}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{member.email}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={member.role === "LEADER" ? "blue" : undefined}
|
||||
style={
|
||||
member.role !== "LEADER"
|
||||
? {
|
||||
backgroundColor: "var(--tool-header-badge-bg)",
|
||||
color: "var(--tool-header-badge-text)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{member.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<Table.Td>
|
||||
{member.role !== "LEADER" && (
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<LocalIcon
|
||||
icon="more-vert"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="person-remove"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
handleRemove(member.id, member.email)
|
||||
}
|
||||
>
|
||||
{t("team.members.remove", "Remove from Team")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
{/* Pending Invitations */}
|
||||
{teamInvitations
|
||||
.filter((inv) => inv.status === "PENDING")
|
||||
.map((invitation) => (
|
||||
<Table.Tr key={`invitation-${invitation.invitationId}`}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500} c="dimmed" fs="italic">
|
||||
{invitation.inviteeEmail.split("@")[0]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{invitation.inviteeEmail}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color="yellow" variant="light">
|
||||
{t("team.members.pending", "PENDING")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
{isTeamLeader && !isPersonalTeam && (
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
handleCancelInvitation(
|
||||
invitation.invitationId,
|
||||
invitation.inviteeEmail,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"team.invite.cancelLabel",
|
||||
"Cancel invitation",
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Loader,
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Text,
|
||||
Flex,
|
||||
} from "@mantine/core";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import { useSaaSPlans } from "@app/hooks/useSaaSPlans";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
|
||||
import { ActiveSubscriptionCard } from "@app/components/shared/config/configSections/plan/ActiveSubscriptionCard";
|
||||
import { SaaSAvailablePlansSection } from "@app/components/shared/config/configSections/plan/SaaSAvailablePlansSection";
|
||||
|
||||
/**
|
||||
* SaaS Plan & Billing section
|
||||
* Shows subscription status, billing information, and usage metrics
|
||||
* Only visible when connected to SaaS
|
||||
*/
|
||||
export function SaasPlanSection() {
|
||||
const { t } = useTranslation();
|
||||
const [isSaasMode, setIsSaasMode] = useState<boolean | null>(null);
|
||||
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||
|
||||
// Billing context
|
||||
const {
|
||||
subscription,
|
||||
usage,
|
||||
tier,
|
||||
isTrialing,
|
||||
trialDaysRemaining,
|
||||
loading,
|
||||
error,
|
||||
refreshBilling,
|
||||
price,
|
||||
currency,
|
||||
isManagedTeamMember,
|
||||
openBillingPortal,
|
||||
} = useSaaSBilling();
|
||||
|
||||
// Team data for ActiveSubscriptionCard
|
||||
const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam();
|
||||
|
||||
// Plans data
|
||||
const { plans, loading: plansLoading, error: plansError } = useSaaSPlans();
|
||||
|
||||
// Check connection mode on mount
|
||||
useEffect(() => {
|
||||
const checkMode = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
setIsSaasMode(mode === "saas");
|
||||
};
|
||||
|
||||
checkMode();
|
||||
|
||||
// Subscribe to mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(
|
||||
async (config) => {
|
||||
setIsSaasMode(config.mode === "saas");
|
||||
},
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Handle "Manage Billing" button click
|
||||
const handleManageBilling = async () => {
|
||||
setIsOpeningPortal(true);
|
||||
|
||||
try {
|
||||
// Context handles opening portal and auto-refresh
|
||||
await openBillingPortal();
|
||||
} catch (error) {
|
||||
console.error("[SaasPlanSection] Failed to open billing portal:", error);
|
||||
} finally {
|
||||
setIsOpeningPortal(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for trial end
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Don't render anything if not in SaaS mode
|
||||
if (isSaasMode === false) {
|
||||
return (
|
||||
<Center p="xl">
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"settings.planBilling.notAvailable",
|
||||
"Plan & Billing is only available when connected to Stirling Cloud (SaaS mode).",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state while checking mode
|
||||
if (isSaasMode === null) {
|
||||
return (
|
||||
<Center p="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state while fetching billing/team data
|
||||
// Note: loading already includes teamLoading from billing context
|
||||
if (loading) {
|
||||
return (
|
||||
<Center p="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.planBilling.loading",
|
||||
"Loading billing information...",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Center p="xl">
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<ErrorOutlineIcon sx={{ fontSize: 16 }} />}
|
||||
title={t(
|
||||
"settings.planBilling.errors.fetchFailed",
|
||||
"Unable to fetch billing data",
|
||||
)}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{error}</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<RefreshIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={refreshBilling}
|
||||
size="xs"
|
||||
>
|
||||
{t("settings.planBilling.errors.retry", "Retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content
|
||||
return (
|
||||
<SaaSCheckoutProvider>
|
||||
<div>
|
||||
{/* Header with title and Manage Billing button */}
|
||||
<Flex justify="space-between" align="center" mb="md">
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("settings.planBilling.currentPlan", "Active Plan")}
|
||||
</h3>
|
||||
{tier !== "free" && !isManagedTeamMember && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handleManageBilling}
|
||||
loading={isOpeningPortal}
|
||||
disabled={isOpeningPortal}
|
||||
>
|
||||
{t(
|
||||
"settings.planBilling.billing.manageBilling",
|
||||
"Manage Billing",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Trial Status Alert */}
|
||||
{isTrialing &&
|
||||
trialDaysRemaining !== undefined &&
|
||||
subscription?.currentPeriodEnd && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t("settings.planBilling.trial.title", "Free Trial Active")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"settings.planBilling.trial.daysRemainingFull",
|
||||
"Your trial ends in {{days}} days",
|
||||
{
|
||||
days: trialDaysRemaining,
|
||||
defaultValue: `Your trial ends in ${trialDaysRemaining} days`,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("settings.planBilling.trial.endDate", "Expires: {{date}}", {
|
||||
date: formatDate(subscription.currentPeriodEnd),
|
||||
defaultValue: `Expires: ${formatDate(subscription.currentPeriodEnd)}`,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<Stack gap="lg">
|
||||
{/* Current subscription card */}
|
||||
<ActiveSubscriptionCard
|
||||
tier={tier}
|
||||
subscription={subscription}
|
||||
usage={usage}
|
||||
isTrialing={isTrialing}
|
||||
price={price}
|
||||
currency={currency}
|
||||
currentTeam={currentTeam}
|
||||
isTeamLeader={isTeamLeader}
|
||||
isPersonalTeam={isPersonalTeam}
|
||||
/>
|
||||
|
||||
{/* Available plans grid */}
|
||||
<SaaSAvailablePlansSection
|
||||
plans={plans}
|
||||
currentTier={tier}
|
||||
loading={plansLoading}
|
||||
error={plansError}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</SaaSCheckoutProvider>
|
||||
);
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Badge,
|
||||
Stack,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import GroupIcon from "@mui/icons-material/Group";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BillingStatus } from "@app/services/saasBillingService";
|
||||
import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface TeamData {
|
||||
teamId: number;
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
isLeader: boolean;
|
||||
seatsUsed: number;
|
||||
}
|
||||
|
||||
interface ActiveSubscriptionCardProps {
|
||||
tier: BillingStatus["tier"];
|
||||
subscription: BillingStatus["subscription"];
|
||||
usage: BillingStatus["meterUsage"];
|
||||
isTrialing: boolean;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
currentTeam?: TeamData | null;
|
||||
isTeamLeader?: boolean;
|
||||
isPersonalTeam?: boolean;
|
||||
}
|
||||
|
||||
export function ActiveSubscriptionCard({
|
||||
tier,
|
||||
subscription,
|
||||
usage,
|
||||
isTrialing,
|
||||
price,
|
||||
currency,
|
||||
currentTeam,
|
||||
isTeamLeader = false,
|
||||
isPersonalTeam = true,
|
||||
}: ActiveSubscriptionCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format timestamp to readable date
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Get tier display name
|
||||
const getTierName = (): string => {
|
||||
switch (tier) {
|
||||
case "free":
|
||||
return t("settings.planBilling.tier.free", "Free Plan");
|
||||
case "team":
|
||||
return t("settings.planBilling.tier.team", "Team Plan");
|
||||
case "enterprise":
|
||||
return t("settings.planBilling.tier.enterprise", "Enterprise Plan");
|
||||
default:
|
||||
return tier;
|
||||
}
|
||||
};
|
||||
|
||||
// Get price display
|
||||
const getPriceDisplay = (): string => {
|
||||
if (tier === "free") {
|
||||
return "$0/month";
|
||||
}
|
||||
// Use actual price from Stripe if available
|
||||
if (price !== undefined && currency) {
|
||||
return `${currency}${price}/month`;
|
||||
}
|
||||
// Fallback to default pricing
|
||||
return "$10/month";
|
||||
};
|
||||
|
||||
// Get description
|
||||
const getDescription = (): string => {
|
||||
if (tier === "free") {
|
||||
return t(
|
||||
"settings.planBilling.tier.freeDescription",
|
||||
"50 credits per month",
|
||||
);
|
||||
}
|
||||
return t(
|
||||
"settings.planBilling.tier.teamDescription",
|
||||
"500 credits/month included, automatic overage billing for uninterrupted service",
|
||||
);
|
||||
};
|
||||
|
||||
// Format overage cost
|
||||
const formatOverageCost = (cents: number, credits: number): string => {
|
||||
return t("settings.planBilling.billing.overageCost", {
|
||||
amount: `$${(cents / 100).toFixed(2)}`,
|
||||
credits,
|
||||
defaultValue: `Current overage cost: $${(cents / 100).toFixed(2)} (${credits} credits)`,
|
||||
});
|
||||
};
|
||||
|
||||
// Pro/Team card
|
||||
if (tier === "team" || tier === "enterprise") {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
{/* Left side: Name, badges, description */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{!isPersonalTeam && isTeamLeader
|
||||
? t("settings.planBilling.tier.team", "Team Plan")
|
||||
: getTierName()}
|
||||
</Text>
|
||||
{!isPersonalTeam && (
|
||||
<Badge
|
||||
color="violet"
|
||||
variant="light"
|
||||
leftSection={<GroupIcon sx={{ fontSize: 12 }} />}
|
||||
>
|
||||
{t("settings.planBilling.tier.teamBadge", "Team")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
label={
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<Text size="sm" mb="xs">
|
||||
{t("settings.planBilling.tier.teamTooltipCredits", {
|
||||
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
||||
defaultValue: `Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month.`,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="sm" mb="xs">
|
||||
{t("settings.planBilling.tier.teamTooltipOverage", {
|
||||
price: getFormattedOveragePrice(),
|
||||
defaultValue: `Automatic overage billing at ${getFormattedOveragePrice()}/credit ensures uninterrupted service.`,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"settings.planBilling.tier.teamTooltipFineprint",
|
||||
"Only pay for what you use beyond included credits.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
withArrow
|
||||
position="right"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<InfoOutlinedIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t("settings.planBilling.status.trial", "Trial")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{!isPersonalTeam && !isTeamLeader && (
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"settings.planBilling.team.managedByTeam",
|
||||
"Managed by team",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{!isPersonalTeam && isTeamLeader && currentTeam && (
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"settings.planBilling.team.memberCount",
|
||||
"{{count}} team members",
|
||||
{ count: currentTeam.seatsUsed },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{getDescription()}
|
||||
</Text>
|
||||
{/* Show overage cost if applicable */}
|
||||
{usage && usage.currentPeriodCredits > 0 && (
|
||||
<Text size="sm" c="orange" fw={500}>
|
||||
{formatOverageCost(
|
||||
usage.estimatedCost,
|
||||
usage.currentPeriodCredits,
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Price */}
|
||||
<div style={{ textAlign: "right" }}>
|
||||
{!isPersonalTeam && !isTeamLeader ? (
|
||||
<Text size="lg" c="dimmed">
|
||||
{t(
|
||||
"settings.planBilling.team.managedByTeam",
|
||||
"Managed by team",
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xl" fw={700}>
|
||||
{getPriceDisplay()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Next billing date at bottom */}
|
||||
{subscription?.currentPeriodEnd && (
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.planBilling.billing.nextBillingDate",
|
||||
"Next billing date:",
|
||||
)}{" "}
|
||||
{formatDate(subscription.currentPeriodEnd)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Free plan card
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{getTierName()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{getDescription()}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Text size="xl" fw={700}>
|
||||
{getPriceDisplay()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { Card, Text, Button, Stack, List, ThemeIcon } from "@mantine/core";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import { BILLING_CONFIG } from "@app/config/billing";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
interface PlanUpgradeCardProps {
|
||||
currentTier: TierLevel;
|
||||
}
|
||||
|
||||
export function PlanUpgradeCard({ currentTier }: PlanUpgradeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Don't show upgrade card if already on Team or Enterprise
|
||||
if (currentTier !== "free") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
// For MVP, direct to web SaaS for upgrades
|
||||
const upgradeUrl = `${STIRLING_SAAS_URL}/account?tab=plan`;
|
||||
|
||||
try {
|
||||
await shellOpen(upgradeUrl);
|
||||
} catch (error) {
|
||||
console.error("[PlanUpgradeCard] Failed to open upgrade URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Text size="lg" fw={600}>
|
||||
{t("settings.planBilling.upgrade.title", "Upgrade Your Plan")}
|
||||
</Text>
|
||||
|
||||
{/* Team plan benefits */}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.planBilling.upgrade.subtitle", "Upgrade to Team for:")}
|
||||
</Text>
|
||||
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon color="blue" size={20} radius="xl">
|
||||
<CheckCircleIcon sx={{ fontSize: 12 }} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
{t("settings.planBilling.upgrade.featureCredits", {
|
||||
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
||||
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
|
||||
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month (vs ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} on Free)`,
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"settings.planBilling.upgrade.featureMembers",
|
||||
"Unlimited team members",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"settings.planBilling.upgrade.featureThroughput",
|
||||
"Faster processing throughput",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"settings.planBilling.upgrade.featureApi",
|
||||
"API access for automation",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"settings.planBilling.upgrade.featureSupport",
|
||||
"Priority support",
|
||||
)}
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
{/* Upgrade button */}
|
||||
<Button variant="filled" fullWidth onClick={handleUpgrade}>
|
||||
{t("settings.planBilling.upgrade.cta", "Upgrade to Team")}
|
||||
</Button>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t(
|
||||
"settings.planBilling.upgrade.opensInBrowser",
|
||||
"Opens in browser to complete upgrade",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { Text, SimpleGrid, Loader, Alert, Center } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/useSaaSPlans";
|
||||
import { SaasPlanCard } from "@app/components/shared/config/configSections/plan/SaasPlanCard";
|
||||
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
interface SaaSAvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentTier?: TierLevel;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const SaaSAvailablePlansSection: React.FC<
|
||||
SaaSAvailablePlansSectionProps
|
||||
> = ({ plans, currentTier, loading, error }) => {
|
||||
const { t } = useTranslation();
|
||||
const { openCheckout } = useSaaSCheckout();
|
||||
|
||||
const handleUpgradeClick = (plan: PlanTier) => {
|
||||
if (plan.isContactOnly) {
|
||||
// Handled by mailto link in the card
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.id === currentTier) {
|
||||
// Already on this plan
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[SaaSAvailablePlansSection] Upgrade clicked for plan:",
|
||||
plan.id,
|
||||
);
|
||||
openCheckout(plan.id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="orange" variant="light" mt="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"plan.availablePlans.loadError",
|
||||
"Unable to load plan pricing. Using default values.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
|
||||
{plans.map((plan) => (
|
||||
<SaasPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentTier}
|
||||
currentTier={currentTier}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/useSaaSPlans";
|
||||
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
|
||||
import type { TierLevel } from "@app/types/billing";
|
||||
|
||||
interface SaasPlanCardProps {
|
||||
plan: PlanTier;
|
||||
isCurrentPlan?: boolean;
|
||||
currentTier?: TierLevel;
|
||||
onUpgradeClick?: (plan: PlanTier) => void;
|
||||
}
|
||||
|
||||
export const SaasPlanCard: React.FC<SaasPlanCardProps> = ({
|
||||
plan,
|
||||
isCurrentPlan,
|
||||
currentTier,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Free plan is included if user has Team or Enterprise tier
|
||||
const isIncluded =
|
||||
plan.id === "free" &&
|
||||
(currentTier === "team" || currentTier === "enterprise");
|
||||
|
||||
// Determine card styling based on plan type
|
||||
const getCardStyle = () => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
borderWidth: 1,
|
||||
position: "relative",
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
if (plan.id === "free" && isCurrentPlan) {
|
||||
return {
|
||||
...baseStyle,
|
||||
borderColor: "var(--border-default)",
|
||||
opacity: 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
if (plan.popular) {
|
||||
return {
|
||||
...baseStyle,
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
borderWidth: 2,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
|
||||
};
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (plan.popular && !isCurrentPlan) {
|
||||
e.currentTarget.style.transform = "translateY(-4px)";
|
||||
e.currentTarget.style.boxShadow = "0 12px 48px rgba(59, 130, 246, 0.3)";
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (plan.popular && !isCurrentPlan) {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "0 2px 8px rgba(59, 130, 246, 0.1)";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (plan.popular && !isCurrentPlan && onUpgradeClick) {
|
||||
onUpgradeClick(plan);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={getCardStyle()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
size="sm"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "rgb(59, 130, 246)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
}}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="sm" className="h-full">
|
||||
<div>
|
||||
<Text size="md" fw={600} mb="xs">
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="xl" fw={700}>
|
||||
{plan.isContactOnly
|
||||
? t("plan.customPricing", "Custom")
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" fw={400} c="dimmed">
|
||||
{plan.isContactOnly
|
||||
? t("plan.enterprise.siteLicense", "Site License")
|
||||
: plan.id === "free"
|
||||
? `50 ${t("credits.modal.monthlyCredits", "monthly credits")}`
|
||||
: plan.overagePrice
|
||||
? `500 ${t("credits.modal.monthlyCredits", "monthly credits")} + ${plan.currency}${plan.overagePrice.toFixed(2)}/${t("credits.modal.overage", "overage")}`
|
||||
: `500 ${t("credits.modal.monthlyCredits", "monthly credits")}`}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} mb="xs">
|
||||
{plan.id === "free"
|
||||
? t("credits.modal.forRegularWork", "For regular PDF work:")
|
||||
: plan.id === "enterprise"
|
||||
? t(
|
||||
"credits.modal.everythingInCredits",
|
||||
"Everything in Credits, plus:",
|
||||
)
|
||||
: t(
|
||||
"credits.modal.everythingInFree",
|
||||
"Everything in Free, plus:",
|
||||
)}
|
||||
</Text>
|
||||
{plan.highlights.map((highlight: string, index: number) => (
|
||||
<FeatureListItem
|
||||
key={index}
|
||||
included
|
||||
color={
|
||||
plan.id === "free"
|
||||
? "var(--mantine-color-gray-6)"
|
||||
: "var(--color-primary-600)"
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{highlight}
|
||||
</FeatureListItem>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan || isIncluded
|
||||
? "subtle"
|
||||
: plan.isContactOnly
|
||||
? "outline"
|
||||
: "filled"
|
||||
}
|
||||
color={plan.isContactOnly ? undefined : "blue"}
|
||||
disabled={isCurrentPlan || isIncluded}
|
||||
fullWidth
|
||||
size="sm"
|
||||
radius="lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpgradeClick?.(plan);
|
||||
}}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
...((isCurrentPlan || isIncluded) && {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "default",
|
||||
}),
|
||||
...(plan.isContactOnly && {
|
||||
borderColor: "var(--text-primary)",
|
||||
color: "var(--text-primary)",
|
||||
}),
|
||||
}}
|
||||
component={plan.isContactOnly ? "a" : undefined}
|
||||
href={
|
||||
plan.isContactOnly
|
||||
? `mailto:contact@stirlingpdf.com?subject=${plan.name} Plan Inquiry`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t("plan.current", "Current Plan")
|
||||
: isIncluded
|
||||
? t("plan.included", "Included")
|
||||
: plan.isContactOnly
|
||||
? t("plan.contact", "Contact Sales")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { Card, Text, Stack, Group, Progress, Alert } from "@mantine/core";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BillingStatus } from "@app/services/saasBillingService";
|
||||
import { BILLING_CONFIG, getFormattedOveragePrice } from "@app/config/billing";
|
||||
|
||||
interface UsageDisplayProps {
|
||||
tier: BillingStatus["tier"];
|
||||
usage: BillingStatus["meterUsage"];
|
||||
}
|
||||
|
||||
export function UsageDisplay({ tier, usage }: UsageDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Credits per month based on tier
|
||||
const getMonthlyCredits = (): number => {
|
||||
switch (tier) {
|
||||
case "free":
|
||||
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
|
||||
case "team":
|
||||
return BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH;
|
||||
case "enterprise":
|
||||
return 1000; // Placeholder — enterprise credits are custom
|
||||
default:
|
||||
return BILLING_CONFIG.FREE_CREDITS_PER_MONTH;
|
||||
}
|
||||
};
|
||||
|
||||
const monthlyCredits = getMonthlyCredits();
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (cents: number): string => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Text size="lg" fw={600}>
|
||||
{t("settings.planBilling.credits.title", "Credit Usage")}
|
||||
</Text>
|
||||
|
||||
{/* Monthly credits info */}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.planBilling.credits.included", {
|
||||
count: monthlyCredits,
|
||||
defaultValue: `${monthlyCredits} credits/month (included)`,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Overage credits (if metered billing enabled) */}
|
||||
{usage && usage.currentPeriodCredits > 0 && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.planBilling.credits.overage", {
|
||||
count: usage.currentPeriodCredits,
|
||||
defaultValue: `+ ${usage.currentPeriodCredits} overage`,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c="orange">
|
||||
{t("settings.planBilling.credits.estimatedCost", {
|
||||
amount: formatCurrency(usage.estimatedCost),
|
||||
defaultValue: `Estimated cost: ${formatCurrency(usage.estimatedCost)}`,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Progress bar for overage usage */}
|
||||
<Progress
|
||||
value={100}
|
||||
color="orange"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
striped
|
||||
animated
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
>
|
||||
<Text size="xs">
|
||||
{t("settings.planBilling.credits.overageInfo", {
|
||||
price: getFormattedOveragePrice(),
|
||||
defaultValue: `Overage credits are billed at ${getFormattedOveragePrice()} per credit. You'll only pay for what you use beyond your monthly allowance.`,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* No overage message */}
|
||||
{(!usage || usage.currentPeriodCredits === 0) && tier !== "free" && (
|
||||
<Alert color="green" variant="light">
|
||||
<Text size="sm">
|
||||
{t("settings.planBilling.credits.noOverage", {
|
||||
count: monthlyCredits,
|
||||
defaultValue: `No overage charges this month. You're using your included ${monthlyCredits} credits.`,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Free tier message */}
|
||||
{tier === "free" && (
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("settings.planBilling.credits.freeTierInfo", {
|
||||
freeCredits: BILLING_CONFIG.FREE_CREDITS_PER_MONTH,
|
||||
teamCredits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
||||
defaultValue: `Free plan includes ${BILLING_CONFIG.FREE_CREDITS_PER_MONTH} credits per month. Upgrade to Team for ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month and pay-as-you-go overage billing.`,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Badge,
|
||||
Button,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import {
|
||||
BILLING_CONFIG,
|
||||
getCurrencySymbol,
|
||||
getFormattedOveragePrice,
|
||||
} from "@app/config/billing";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { CreditUsageBanner } from "@app/components/shared/modals/CreditUsageBanner";
|
||||
import { FeatureListItem } from "@app/components/shared/modals/FeatureListItem";
|
||||
import {
|
||||
FREE_PLAN_FEATURES,
|
||||
TEAM_PLAN_FEATURES,
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
} from "@app/config/planFeatures";
|
||||
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
|
||||
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
|
||||
|
||||
interface CreditExhaustedModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop Credit Exhausted Modal
|
||||
* Shows upgrade options when user runs out of credits
|
||||
* Routes to different UI based on user status (free/team/managed member)
|
||||
*/
|
||||
export function CreditExhaustedModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: CreditExhaustedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { creditBalance, tier, plans, refreshBilling, isManagedTeamMember } =
|
||||
useSaaSBilling();
|
||||
const { isTeamLeader } = useSaaSTeam();
|
||||
const { openCheckout } = useSaaSCheckout();
|
||||
|
||||
const { enablingMetering, meteringError, handleEnableMetering } =
|
||||
useEnableMeteredBilling(refreshBilling, onClose, "CreditExhaustedModal");
|
||||
|
||||
// Managed team members have unlimited credits via team
|
||||
if (isManagedTeamMember) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
centered
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
title={t("credits.modal.managedMemberTitle", "Unlimited Credits")}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"credits.modal.managedMemberMessage",
|
||||
"You have unlimited access to credits through your team. If you need assistance, please contact your team leader.",
|
||||
)}
|
||||
</Text>
|
||||
<Button onClick={onClose} fullWidth>
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Team users should enable overage billing
|
||||
// Only team leaders can enable metered billing, members see different UI
|
||||
if (tier === "team") {
|
||||
const teamPlan = plans.get("team");
|
||||
const teamCurrency = teamPlan?.currency ?? "$";
|
||||
const overagePrice =
|
||||
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
||||
const formattedOveragePrice = getFormattedOveragePrice(
|
||||
teamCurrency,
|
||||
overagePrice,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
closeOnClickOutside={!enablingMetering}
|
||||
closeOnEscape={!enablingMetering}
|
||||
centered
|
||||
size="lg"
|
||||
radius="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
title={
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={450}>
|
||||
{t(
|
||||
"credits.modal.titleExhaustedPro",
|
||||
"You have run out of credits",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"credits.modal.subtitlePro",
|
||||
"Enable automatic overage billing to never run out of credits.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
styles={{
|
||||
body: { padding: "0rem 0rem 0.5rem 0rem" },
|
||||
content: {
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
header: {
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<CreditUsageBanner
|
||||
currentCredits={creditBalance}
|
||||
totalCredits={BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}
|
||||
/>
|
||||
|
||||
{meteringError && (
|
||||
<Alert color="red" ml="lg" mr="lg">
|
||||
{meteringError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Explanation Card */}
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
ml="lg"
|
||||
mr="lg"
|
||||
style={{
|
||||
borderColor: "var(--color-primary-600)",
|
||||
borderWidth: 2,
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" align="center">
|
||||
<TrendingUpIcon
|
||||
sx={{ fontSize: 24, color: "var(--color-primary-600)" }}
|
||||
/>
|
||||
<Text size="lg" fw={600}>
|
||||
{t(
|
||||
"credits.modal.meteringTitle",
|
||||
"Pay-What-You-Use Overage Billing",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("credits.modal.meteringExplanation", {
|
||||
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
||||
defaultValue: `Your Team plan includes ${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working.`,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<FeatureListItem included>
|
||||
{t("credits.modal.meteringIncluded", {
|
||||
credits: BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH,
|
||||
defaultValue: `${BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH} credits/month included with Team`,
|
||||
})}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t(
|
||||
"credits.modal.meteringPrice",
|
||||
"Additional credits at {{price}}/credit",
|
||||
{
|
||||
price: formattedOveragePrice,
|
||||
},
|
||||
)}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t(
|
||||
"credits.modal.meteringPayAsYouGo",
|
||||
"Only pay for what you use",
|
||||
)}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t(
|
||||
"credits.modal.meteringNoCommitment",
|
||||
"No commitment, cancel anytime",
|
||||
)}
|
||||
</FeatureListItem>
|
||||
<FeatureListItem included>
|
||||
{t(
|
||||
"credits.modal.meteringNeverRunOut",
|
||||
"Never run out of credits",
|
||||
)}
|
||||
</FeatureListItem>
|
||||
</Stack>
|
||||
|
||||
<Card
|
||||
padding="md"
|
||||
radius="md"
|
||||
style={{
|
||||
backgroundColor: "light-dark(#F8F9FA, #1A1A1E)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"credits.modal.meteringBillingNote",
|
||||
"Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings.",
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack gap="sm" ml="lg" mr="lg">
|
||||
<Button
|
||||
onClick={handleEnableMetering}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
size="lg"
|
||||
loading={enablingMetering}
|
||||
disabled={!isTeamLeader}
|
||||
leftSection={<TrendingUpIcon sx={{ fontSize: 18 }} />}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t("credits.enableOverageBilling", "Enable Overage Billing")}
|
||||
</Button>
|
||||
{!isTeamLeader && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t(
|
||||
"credits.modal.teamLeaderOnly",
|
||||
"Only team leaders can enable overage billing",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
size="md"
|
||||
c="dimmed"
|
||||
disabled={enablingMetering}
|
||||
>
|
||||
{t("credits.maybeLater", "Maybe later")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Free tier users - show upgrade modal
|
||||
const teamPlan = plans.get("team");
|
||||
const teamPrice = teamPlan?.price ?? 20;
|
||||
const teamCurrency = teamPlan?.currency ?? "$";
|
||||
const overagePrice =
|
||||
teamPlan?.overagePrice ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
||||
|
||||
const currencySymbol = getCurrencySymbol(teamCurrency);
|
||||
const formattedOveragePrice = `${currencySymbol}${overagePrice.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
closeOnClickOutside
|
||||
closeOnEscape
|
||||
centered
|
||||
size="60rem"
|
||||
radius="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
title={
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={450}>
|
||||
{t("credits.modal.titleExhausted", "You've used your free credits")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"credits.modal.subtitle",
|
||||
"Upgrade to Team for 10x the credits and faster processing.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
styles={{
|
||||
body: { padding: "0rem 0rem 0.5rem 0rem" },
|
||||
content: {
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
header: {
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: "light-dark(rgba(0,0,0,0.5), rgba(0,0,0,0.7))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<CreditUsageBanner
|
||||
currentCredits={creditBalance}
|
||||
totalCredits={BILLING_CONFIG.FREE_CREDITS_PER_MONTH}
|
||||
/>
|
||||
|
||||
<Group gap="md" ml="lg" mr="lg" align="stretch" grow>
|
||||
{/* Free Plan Card */}
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--border-default)",
|
||||
borderWidth: 1,
|
||||
opacity: 0.85,
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="xs">
|
||||
{t("credits.modal.freeTier", "Free Tier")}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="1.75rem" fw={700}>
|
||||
{currencySymbol}0
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("credits.modal.perMonth", "/month")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{BILLING_CONFIG.FREE_CREDITS_PER_MONTH}{" "}
|
||||
{t("credits.modal.monthlyCredits", "monthly credits")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t("credits.modal.forRegularWork", "For regular PDF work:")}
|
||||
</Text>
|
||||
{FREE_PLAN_FEATURES.map((feature, index) => (
|
||||
<FeatureListItem
|
||||
key={index}
|
||||
included
|
||||
color="var(--mantine-color-gray-6)"
|
||||
>
|
||||
{t(feature.translationKey, feature.defaultText)}
|
||||
</FeatureListItem>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
disabled
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{t("credits.modal.current", "Current Plan")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Team Plan Card */}
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--card-selected-border)",
|
||||
borderWidth: 2,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
position: "relative",
|
||||
boxShadow: "0 2px 8px rgba(59, 130, 246, 0.1)",
|
||||
overflow: "visible",
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
onClick={() => openCheckout("pro")}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-4px)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
"0 12px 48px rgba(59, 130, 246, 0.3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
"0 2px 8px rgba(59, 130, 246, 0.1)";
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "rgb(59, 130, 246)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
}}
|
||||
>
|
||||
{t("credits.modal.popular", "Popular")}
|
||||
</Badge>
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="xs">
|
||||
{t("credits.modal.teamSubscription", "Team")}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline" mt="xs">
|
||||
<Text
|
||||
size="1.75rem"
|
||||
fw={700}
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{currencySymbol}
|
||||
{teamPrice}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("credits.modal.perMonth", "/month")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{BILLING_CONFIG.INCLUDED_CREDITS_PER_MONTH}{" "}
|
||||
{t("credits.modal.monthlyCredits", "monthly credits")} +{" "}
|
||||
{formattedOveragePrice}/
|
||||
{t("credits.modal.overage", "overage")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"credits.modal.everythingInFree",
|
||||
"Everything in Free, plus:",
|
||||
)}
|
||||
</Text>
|
||||
{TEAM_PLAN_FEATURES.map((feature, index) => (
|
||||
<FeatureListItem key={index} included>
|
||||
{t(feature.translationKey, feature.defaultText)}
|
||||
</FeatureListItem>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
onClick={() => openCheckout("pro")}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t("credits.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Enterprise Plan Card */}
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
backgroundColor: "light-dark(#FFFFFF, #1A1A1E)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="md" fw={600} mb="xs">
|
||||
{t("credits.modal.enterpriseSubscription", "Enterprise")}
|
||||
</Text>
|
||||
<Text size="1.75rem" fw={600}>
|
||||
{t("credits.modal.customPricing", "Custom")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t("credits.modal.unlimitedMonthlyCredits", "Site License")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"credits.modal.everythingInCredits",
|
||||
"Everything in Credits, plus:",
|
||||
)}
|
||||
</Text>
|
||||
{ENTERPRISE_PLAN_FEATURES.map((feature, index) => (
|
||||
<FeatureListItem key={index} included>
|
||||
{t(feature.translationKey, feature.defaultText)}
|
||||
</FeatureListItem>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href="mailto:contact@stirlingpdf.com?subject=Enterprise Plan Inquiry"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
borderColor: "var(--text-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t("credits.modal.contactSales", "Contact Sales")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" ta="center" c="dimmed" mt="md">
|
||||
{t("credits.modal.selfHostPrompt", "Want to self host?")}{" "}
|
||||
<Text
|
||||
component="a"
|
||||
href="https://www.stirling.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
style={{
|
||||
color: "var(--mantine-color-blue-6)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.textDecoration = "underline";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.textDecoration = "none";
|
||||
}}
|
||||
>
|
||||
{t("credits.modal.selfHostLink", "Review the docs and plans")}
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSMode } from "@app/hooks/useSaaSMode";
|
||||
import { BILLING_CONFIG } from "@app/config/billing";
|
||||
import { CreditExhaustedModal } from "@app/components/shared/modals/CreditExhaustedModal";
|
||||
import { InsufficientCreditsModal } from "@app/components/shared/modals/InsufficientCreditsModal";
|
||||
import { useCreditEvents } from "@app/hooks/useCreditEvents";
|
||||
import { CREDIT_EVENTS } from "@app/constants/creditEvents";
|
||||
|
||||
/**
|
||||
* Desktop Credit Modal Bootstrap
|
||||
* Listens to credit events and shows appropriate modals
|
||||
* Orchestrates credit exhausted and insufficient credits modals
|
||||
*/
|
||||
export function CreditModalBootstrap() {
|
||||
const [exhaustedOpen, setExhaustedOpen] = useState(false);
|
||||
const [insufficientOpen, setInsufficientOpen] = useState(false);
|
||||
const [insufficientDetails, setInsufficientDetails] = useState<{
|
||||
toolId?: string;
|
||||
requiredCredits?: number;
|
||||
}>({});
|
||||
|
||||
const isSaaSMode = useSaaSMode();
|
||||
const {
|
||||
creditBalance,
|
||||
isManagedTeamMember,
|
||||
lastFetchTime,
|
||||
plansLastFetchTime,
|
||||
refreshPlans,
|
||||
} = useSaaSBilling();
|
||||
|
||||
// Preload plan pricing when billing confirms credits are low.
|
||||
// Fires once: only when in SaaS mode, billing has loaded (lastFetchTime set) and plans haven't been
|
||||
// fetched yet (plansLastFetchTime null). This way the modal shows real prices instantly.
|
||||
useEffect(() => {
|
||||
if (
|
||||
isSaaSMode &&
|
||||
lastFetchTime !== null &&
|
||||
plansLastFetchTime === null &&
|
||||
creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD &&
|
||||
!isManagedTeamMember
|
||||
) {
|
||||
refreshPlans();
|
||||
}
|
||||
}, [
|
||||
isSaaSMode,
|
||||
lastFetchTime,
|
||||
plansLastFetchTime,
|
||||
creditBalance,
|
||||
isManagedTeamMember,
|
||||
refreshPlans,
|
||||
]);
|
||||
|
||||
// Monitor credit balance and dispatch events
|
||||
useCreditEvents();
|
||||
|
||||
useEffect(() => {
|
||||
const handleExhausted = () => {
|
||||
// Don't show modal for managed team members
|
||||
if (isManagedTeamMember) {
|
||||
return;
|
||||
}
|
||||
setExhaustedOpen(true);
|
||||
};
|
||||
|
||||
const handleInsufficient = (e: Event) => {
|
||||
// Don't show modal for managed team members
|
||||
if (isManagedTeamMember) {
|
||||
return;
|
||||
}
|
||||
const customEvent = e as CustomEvent;
|
||||
setInsufficientDetails({
|
||||
toolId: customEvent.detail?.operationType,
|
||||
requiredCredits: customEvent.detail?.requiredCredits,
|
||||
});
|
||||
// Show the plans banner (CreditExhaustedModal) instead of the simpler
|
||||
// InsufficientCreditsModal — same experience as clicking the upgrade button.
|
||||
setExhaustedOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
|
||||
window.addEventListener(CREDIT_EVENTS.INSUFFICIENT, handleInsufficient);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
|
||||
window.removeEventListener(
|
||||
CREDIT_EVENTS.INSUFFICIENT,
|
||||
handleInsufficient,
|
||||
);
|
||||
};
|
||||
}, [isManagedTeamMember, creditBalance]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreditExhaustedModal
|
||||
opened={exhaustedOpen && !insufficientOpen}
|
||||
onClose={() => setExhaustedOpen(false)}
|
||||
/>
|
||||
<InsufficientCreditsModal
|
||||
opened={insufficientOpen}
|
||||
onClose={() => setInsufficientOpen(false)}
|
||||
toolId={insufficientDetails.toolId}
|
||||
requiredCredits={insufficientDetails.requiredCredits}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Divider, Group, Text, Progress, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CreditUsageBannerProps {
|
||||
currentCredits: number;
|
||||
totalCredits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit usage banner showing remaining credits with progress bar
|
||||
* Used in credit exhausted and upgrade modals
|
||||
*/
|
||||
export function CreditUsageBanner({
|
||||
currentCredits,
|
||||
totalCredits,
|
||||
}: CreditUsageBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const percentageRemaining =
|
||||
totalCredits > 0 ? (currentCredits / totalCredits) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Divider />
|
||||
<Stack gap="xs" pr="md" pl="md">
|
||||
<Group gap="xs" justify="space-between" align="center">
|
||||
<Text size="md" fw={400} c="dimmed">
|
||||
{t("credits.modal.creditsThisMonth", "Monthly credits")}
|
||||
</Text>
|
||||
<Text size="md" fw={600} style={{ color: "var(--text-primary)" }}>
|
||||
{t(
|
||||
"credits.modal.creditsRemaining",
|
||||
"{{current}} of {{total}} remaining",
|
||||
{
|
||||
current: currentCredits,
|
||||
total: totalCredits,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentageRemaining}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color="blue"
|
||||
styles={{
|
||||
root: { backgroundColor: "var(--bg-raised)" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import CheckCircleIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface FeatureListItemProps {
|
||||
children: React.ReactNode;
|
||||
included: boolean;
|
||||
color?: string;
|
||||
dimmed?: boolean;
|
||||
fw?: number;
|
||||
size?: "xs" | "sm" | "md" | "lg" | string;
|
||||
}
|
||||
|
||||
export function FeatureListItem({
|
||||
children,
|
||||
included,
|
||||
color = "var(--color-primary-600)",
|
||||
dimmed = false,
|
||||
fw = 400,
|
||||
size = "sm",
|
||||
}: FeatureListItemProps) {
|
||||
const Icon = included ? CheckCircleIcon : CloseIcon;
|
||||
const iconColor = included ? color : "var(--color-red-600)";
|
||||
|
||||
// Map Mantine sizes to icon font sizes
|
||||
const iconSizeMap: Record<string, number> = {
|
||||
xs: 14,
|
||||
sm: 16,
|
||||
md: 18,
|
||||
lg: 20,
|
||||
};
|
||||
|
||||
// Determine icon size - use mapped value if it exists, otherwise use the string directly
|
||||
const iconSize = iconSizeMap[size] || size;
|
||||
|
||||
// For Text component, only use Mantine sizes if the size is a predefined key
|
||||
const textSize = iconSizeMap[size] ? size : undefined;
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Icon
|
||||
sx={{
|
||||
fontSize: iconSize,
|
||||
color: iconColor,
|
||||
flexShrink: 0,
|
||||
marginTop: "2px",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size={textSize}
|
||||
c={dimmed ? "dimmed" : undefined}
|
||||
fw={fw}
|
||||
style={textSize ? undefined : { fontSize: size }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Modal, Stack, Text, Button, Alert, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { useSaaSCheckout } from "@app/contexts/SaaSCheckoutContext";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { useEnableMeteredBilling } from "@app/hooks/useEnableMeteredBilling";
|
||||
|
||||
interface InsufficientCreditsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
toolId?: string;
|
||||
requiredCredits?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop Insufficient Credits Modal
|
||||
* Shows when user attempts operation without enough credits
|
||||
*/
|
||||
export function InsufficientCreditsModal({
|
||||
opened,
|
||||
onClose,
|
||||
toolId,
|
||||
requiredCredits,
|
||||
}: InsufficientCreditsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { creditBalance, tier, refreshBilling, isManagedTeamMember } =
|
||||
useSaaSBilling();
|
||||
const { isTeamLeader } = useSaaSTeam();
|
||||
const { openCheckout } = useSaaSCheckout();
|
||||
|
||||
const { enablingMetering, meteringError, handleEnableMetering } =
|
||||
useEnableMeteredBilling(
|
||||
refreshBilling,
|
||||
onClose,
|
||||
"InsufficientCreditsModal",
|
||||
);
|
||||
|
||||
const toolName = toolId
|
||||
? t(`tool.${toolId}.name`, toolId)
|
||||
: t("common.operation", "this operation");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
centered
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<WarningIcon
|
||||
sx={{ fontSize: 24, color: "var(--mantine-color-orange-6)" }}
|
||||
/>
|
||||
<Text size="lg" fw={500}>
|
||||
{t("credits.insufficient.title", "Insufficient Credits")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="orange" icon={<WarningIcon />}>
|
||||
<Text size="sm">
|
||||
{requiredCredits
|
||||
? t(
|
||||
"credits.insufficient.messageWithAmount",
|
||||
"You need {{required}} credits to run {{tool}}, but you only have {{current}}.",
|
||||
{
|
||||
required: requiredCredits,
|
||||
tool: toolName,
|
||||
current: creditBalance,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"credits.insufficient.message",
|
||||
"You do not have enough credits to run {{tool}}. You currently have {{current}} credits.",
|
||||
{
|
||||
tool: toolName,
|
||||
current: creditBalance,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isManagedTeamMember ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"credits.insufficient.managedMember",
|
||||
"Please contact your team leader for assistance.",
|
||||
)}
|
||||
</Text>
|
||||
<Button onClick={onClose} fullWidth>
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</>
|
||||
) : tier === "team" ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"credits.insufficient.teamMember",
|
||||
"Enable overage billing to never run out of credits.",
|
||||
)}
|
||||
</Text>
|
||||
{meteringError && <Alert color="red">{meteringError}</Alert>}
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
onClick={handleEnableMetering}
|
||||
loading={enablingMetering}
|
||||
disabled={!isTeamLeader}
|
||||
>
|
||||
{t("credits.enableOverageBilling", "Enable Overage Billing")}
|
||||
</Button>
|
||||
{!isTeamLeader && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t(
|
||||
"credits.modal.teamLeaderOnly",
|
||||
"Only team leaders can enable overage billing",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
disabled={enablingMetering}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"credits.insufficient.freeTier",
|
||||
"Upgrade to Team for 10x more credits and unlimited overage billing.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
openCheckout("pro");
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("credits.upgrade", "Upgrade to Team")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="subtle" fullWidth>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Billing Configuration for Desktop
|
||||
* Single source of truth for billing-related constants
|
||||
*/
|
||||
|
||||
export const BILLING_CONFIG = {
|
||||
// Credits included in Free plan (per month)
|
||||
FREE_CREDITS_PER_MONTH: 50,
|
||||
|
||||
// Credits included in Pro plan (per month)
|
||||
INCLUDED_CREDITS_PER_MONTH: 500,
|
||||
|
||||
// Overage pricing (per credit) - also fetched dynamically from Stripe
|
||||
OVERAGE_PRICE_PER_CREDIT: 0.05,
|
||||
|
||||
// Credit warning threshold (shows low-balance indicator)
|
||||
LOW_CREDIT_THRESHOLD: 10,
|
||||
|
||||
// Threshold at which plan pricing is preloaded (higher than LOW_CREDIT_THRESHOLD
|
||||
// so prices are ready before the warning UI appears)
|
||||
PLAN_PRICING_PRELOAD_THRESHOLD: 20,
|
||||
|
||||
// Stripe lookup keys
|
||||
PRO_PLAN_LOOKUP_KEY: "plan:pro",
|
||||
METER_LOOKUP_KEY: "meter:overage",
|
||||
|
||||
// Display formats
|
||||
CURRENCY_SYMBOLS: {
|
||||
gbp: "£",
|
||||
usd: "$",
|
||||
eur: "€",
|
||||
cny: "¥",
|
||||
inr: "₹",
|
||||
brl: "R$",
|
||||
idr: "Rp",
|
||||
jpy: "¥",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get current billing configuration
|
||||
*/
|
||||
export function getBillingConfig() {
|
||||
return BILLING_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format overage price with currency symbol
|
||||
* @param currency Currency code (e.g., 'usd', 'gbp')
|
||||
* @param price Optional price override (defaults to BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT)
|
||||
*/
|
||||
export function getFormattedOveragePrice(
|
||||
currency: string = "usd",
|
||||
price?: number,
|
||||
): string {
|
||||
const symbol =
|
||||
BILLING_CONFIG.CURRENCY_SYMBOLS[
|
||||
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
|
||||
] || "$";
|
||||
const amount = price ?? BILLING_CONFIG.OVERAGE_PRICE_PER_CREDIT;
|
||||
return `${symbol}${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol from currency code
|
||||
*/
|
||||
export function getCurrencySymbol(currency: string): string {
|
||||
return (
|
||||
BILLING_CONFIG.CURRENCY_SYMBOLS[
|
||||
currency.toLowerCase() as keyof typeof BILLING_CONFIG.CURRENCY_SYMBOLS
|
||||
] || currency.toUpperCase()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Credit event constants for desktop credit system
|
||||
* Used for communication between credit monitoring, UI, and operations
|
||||
*/
|
||||
|
||||
export const CREDIT_EVENTS = {
|
||||
EXHAUSTED: "credits:exhausted",
|
||||
INSUFFICIENT: "credits:insufficient",
|
||||
REFRESH_NEEDED: "credits:refresh-needed",
|
||||
} as const;
|
||||
|
||||
export type CreditEventType =
|
||||
(typeof CREDIT_EVENTS)[keyof typeof CREDIT_EVENTS];
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { SaaSStripeCheckout } from "@app/components/shared/billing/SaaSStripeCheckout";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
|
||||
interface SaaSCheckoutContextType {
|
||||
opened: boolean;
|
||||
selectedPlan: string | null;
|
||||
openCheckout: (planId: string) => void;
|
||||
closeCheckout: () => void;
|
||||
}
|
||||
|
||||
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useSaaSCheckout = () => {
|
||||
const context = useContext(SaaSCheckoutContext);
|
||||
if (!context) {
|
||||
throw new Error("useSaaSCheckout must be used within SaaSCheckoutProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SaaSCheckoutProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
|
||||
// Access billing context for auto-refresh after checkout
|
||||
const { refreshBilling } = useSaaSBilling();
|
||||
|
||||
const openCheckout = (planId: string) => {
|
||||
setSelectedPlan(planId);
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const closeCheckout = () => {
|
||||
setOpened(false);
|
||||
// Don't reset selectedPlan immediately to allow for cleanup
|
||||
setTimeout(() => setSelectedPlan(null), 300);
|
||||
};
|
||||
|
||||
// Internal success handler - automatically refreshes billing context
|
||||
const handleCheckoutSuccess = useCallback(async () => {
|
||||
// Wait for webhooks to process (2 seconds)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
try {
|
||||
await refreshBilling();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SaaSCheckoutContext] Failed to refresh billing after checkout:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, [refreshBilling]);
|
||||
|
||||
return (
|
||||
<SaaSCheckoutContext.Provider
|
||||
value={{
|
||||
opened,
|
||||
selectedPlan,
|
||||
openCheckout,
|
||||
closeCheckout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SaaSStripeCheckout
|
||||
opened={opened}
|
||||
onClose={closeCheckout}
|
||||
planId={selectedPlan}
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
/>
|
||||
</SaaSCheckoutContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { authService } from "@app/services/authService";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Team Context
|
||||
* Provides team management for users connected to SaaS backend
|
||||
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
|
||||
*/
|
||||
|
||||
interface Team {
|
||||
teamId: number;
|
||||
name: string;
|
||||
teamType: string;
|
||||
isPersonal: boolean;
|
||||
memberCount: number;
|
||||
seatCount: number;
|
||||
seatsUsed: number;
|
||||
maxSeats: number;
|
||||
isLeader: boolean;
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
interface TeamInvitation {
|
||||
invitationId: number;
|
||||
teamName: string;
|
||||
inviterEmail: string;
|
||||
inviteeEmail: string;
|
||||
invitationToken: string;
|
||||
status: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface SaaSTeamContextType {
|
||||
currentTeam: Team | null;
|
||||
teams: Team[];
|
||||
teamMembers: TeamMember[];
|
||||
teamInvitations: TeamInvitation[];
|
||||
receivedInvitations: TeamInvitation[];
|
||||
isTeamLeader: boolean;
|
||||
isPersonalTeam: boolean;
|
||||
loading: boolean;
|
||||
|
||||
inviteUser: (email: string) => Promise<void>;
|
||||
acceptInvitation: (token: string) => Promise<void>;
|
||||
rejectInvitation: (token: string) => Promise<void>;
|
||||
cancelInvitation: (invitationId: number) => Promise<void>;
|
||||
removeMember: (memberId: number) => Promise<void>;
|
||||
leaveTeam: () => Promise<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SaaSTeamContext = createContext<SaaSTeamContextType>({
|
||||
currentTeam: null,
|
||||
teams: [],
|
||||
teamMembers: [],
|
||||
teamInvitations: [],
|
||||
receivedInvitations: [],
|
||||
isTeamLeader: false,
|
||||
isPersonalTeam: true,
|
||||
loading: true,
|
||||
inviteUser: async () => {},
|
||||
acceptInvitation: async () => {},
|
||||
rejectInvitation: async () => {},
|
||||
cancelInvitation: async () => {},
|
||||
removeMember: async () => {},
|
||||
leaveTeam: async () => {},
|
||||
refreshTeams: async () => {},
|
||||
});
|
||||
|
||||
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTeam, setCurrentTeam] = useState<Team | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
|
||||
const [receivedInvitations, setReceivedInvitations] = useState<
|
||||
TeamInvitation[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Check if in SaaS mode and authenticated
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === "saas");
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
|
||||
// Subscribe to connection mode changes
|
||||
const unsubscribe =
|
||||
connectionModeService.subscribeToModeChanges(checkAccess);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === "authenticated");
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const fetchMyTeams = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log(
|
||||
"[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<Team[]>("/api/v1/team/my", {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
setTeams(response.data);
|
||||
|
||||
const activeTeam = response.data[0];
|
||||
console.log("[SaaSTeamContext] Current team set:", {
|
||||
teamId: activeTeam?.teamId,
|
||||
name: activeTeam?.name,
|
||||
isPersonal: activeTeam?.isPersonal,
|
||||
isLeader: activeTeam?.isLeader,
|
||||
});
|
||||
setCurrentTeam(activeTeam || null);
|
||||
return activeTeam || null;
|
||||
} catch (error) {
|
||||
console.error("[SaaSTeamContext] Failed to fetch teams:", error);
|
||||
return null;
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
const fetchTeamMembers = useCallback(
|
||||
async (teamId: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log(
|
||||
"[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamMember[]>(
|
||||
`/api/v1/team/${teamId}/members`,
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
setTeamMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error("[SaaSTeamContext] Failed to fetch team members:", error);
|
||||
}
|
||||
},
|
||||
[isSaasMode, isAuthenticated],
|
||||
);
|
||||
|
||||
const fetchTeamInvitations = useCallback(
|
||||
async (teamId?: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated || !teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(
|
||||
`/api/v1/team/${teamId}/invitations`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
setTeamInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SaaSTeamContext] Failed to fetch team invitations:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
[isSaasMode, isAuthenticated],
|
||||
);
|
||||
|
||||
const fetchReceivedInvitations = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SaaSTeamContext] Fetching received team invitations");
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(
|
||||
"/api/v1/team/invitations/pending",
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
console.log(
|
||||
"[SaaSTeamContext] Received invitations response:",
|
||||
response.data,
|
||||
);
|
||||
setReceivedInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SaaSTeamContext] Failed to fetch received invitations:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
fetchMyTeams();
|
||||
fetchReceivedInvitations();
|
||||
} else {
|
||||
// Clear state when not in SaaS mode or not authenticated
|
||||
setTeams([]);
|
||||
setCurrentTeam(null);
|
||||
setTeamMembers([]);
|
||||
setTeamInvitations([]);
|
||||
setReceivedInvitations([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentTeam &&
|
||||
!currentTeam.isPersonal &&
|
||||
isSaasMode &&
|
||||
isAuthenticated
|
||||
) {
|
||||
fetchTeamMembers(currentTeam.teamId);
|
||||
// Only fetch invitations if user is team leader
|
||||
if (currentTeam.isLeader) {
|
||||
fetchTeamInvitations(currentTeam.teamId);
|
||||
} else {
|
||||
setTeamInvitations([]);
|
||||
}
|
||||
} else {
|
||||
setTeamMembers([]);
|
||||
setTeamInvitations([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [
|
||||
currentTeam,
|
||||
isSaasMode,
|
||||
isAuthenticated,
|
||||
fetchTeamMembers,
|
||||
fetchTeamInvitations,
|
||||
]);
|
||||
|
||||
const inviteUser = async (email: string) => {
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post("/api/v1/team/invite", {
|
||||
teamId: currentTeam.teamId,
|
||||
email,
|
||||
});
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const acceptInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
|
||||
await fetchReceivedInvitations();
|
||||
await refreshTeams();
|
||||
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
|
||||
};
|
||||
|
||||
const rejectInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
|
||||
await fetchReceivedInvitations();
|
||||
};
|
||||
|
||||
const cancelInvitation = async (invitationId: number) => {
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
|
||||
if (currentTeam) {
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (memberId: number) => {
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.delete(
|
||||
`/api/v1/team/${currentTeam.teamId}/members/${memberId}`,
|
||||
);
|
||||
await refreshTeams();
|
||||
await fetchTeamMembers(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const leaveTeam = async () => {
|
||||
if (!currentTeam) throw new Error("No current team");
|
||||
if (!isSaasMode) throw new Error("Not in SaaS mode");
|
||||
|
||||
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
|
||||
await refreshTeams();
|
||||
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
|
||||
};
|
||||
|
||||
const refreshTeams = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log(
|
||||
"[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newCurrentTeam = await fetchMyTeams();
|
||||
await fetchReceivedInvitations();
|
||||
if (newCurrentTeam && !newCurrentTeam.isPersonal) {
|
||||
await fetchTeamMembers(newCurrentTeam.teamId);
|
||||
// Only fetch invitations if user is team leader
|
||||
if (newCurrentTeam.isLeader) {
|
||||
await fetchTeamInvitations(newCurrentTeam.teamId);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isSaasMode,
|
||||
isAuthenticated,
|
||||
fetchMyTeams,
|
||||
fetchReceivedInvitations,
|
||||
fetchTeamMembers,
|
||||
fetchTeamInvitations,
|
||||
]);
|
||||
|
||||
const isTeamLeader = currentTeam?.isLeader ?? false;
|
||||
const isPersonalTeam = currentTeam?.isPersonal ?? true;
|
||||
|
||||
return (
|
||||
<SaaSTeamContext.Provider
|
||||
value={{
|
||||
currentTeam,
|
||||
teams,
|
||||
teamMembers,
|
||||
teamInvitations,
|
||||
receivedInvitations,
|
||||
isTeamLeader,
|
||||
isPersonalTeam,
|
||||
loading,
|
||||
inviteUser,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
cancelInvitation,
|
||||
removeMember,
|
||||
leaveTeam,
|
||||
refreshTeams,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SaaSTeamContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSaaSTeam() {
|
||||
const context = useContext(SaaSTeamContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSaaSTeam must be used within a SaaSTeamProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SaaSTeamContext };
|
||||
export type { Team, TeamMember, TeamInvitation };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user