mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
PAYG: PricingPolicyService + admin REST + 30s read cache (#6469)
## What this is PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data model from #6460 — answers "what pricing policy applies to this team right now?" with a fast cache and an admin write surface. ## Scope | Piece | Where | |---|---| | `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s Caffeine cache + admin write paths | `app/saas/.../payg/policy/PricingPolicyService.java` | | `PolicyChangedEvent` — published after admin writes for in-process cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java` | | Admin REST — list / get / create / set-default / set team override / get effective | `app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` + DTOs | | `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for set-default | repository update | | `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going stale (carried over from the #6460 review concern) | new test | | V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit, per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` | ## Lookup precedence 1. `PaygTeamExtensions.pricingPolicyId` set → return that policy 2. Else return the `pricing_policy` row with `is_default = TRUE` 3. Override row points at a deleted policy → log warn, fall back to default (safety net for racing deletes) 4. No default → `IllegalStateException` (V12 seed guarantees one exists) ## Cache behaviour - 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`. - **Single correctness model: the TTL.** Cross-instance propagation is at-most-30-seconds. The writer instance sees its own change immediately via the `PolicyChangedEvent` after-commit publish. Other instances pick it up on the next TTL expiry. - Admin reads use `getEffectivePolicyUncached` so admins always see their own write straight back. **Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a Postgres `LISTEN policy_changed` runner so cross-instance propagation was instant. Dropped — admin policy changes are events-per-week and the 30s TTL is already the correctness floor; the listener was ~250 lines of nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect loop, lock-protected connection lifecycle) for a use case that isn't on the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with three concrete triggers that would justify reintroducing it (aggressive cap enforcement, Redis landing for other reasons, real-time admin UI). ## Writes — transactional, fire `PolicyChangedEvent` after commit - `create(draft)` — rejects pre-set `policy_id` or `is_default=true` (promotion must go through `setDefault` so the partial unique idx is freed first). - `setDefault(id)` — atomically clears the existing default via `clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if the row is already default. - `setTeamOverride(teamId, policyId | null)` — validates the policy exists before save; `null` clears the override. `publishOnCommit` uses `TransactionSynchronizationManager.afterCommit` so listeners never see pre-commit state. Outside a transaction (test paths) falls through to immediate publish. ## Admin REST surface — `/api/v1/admin/payg/...` All endpoints `@PreAuthorize("hasRole('ADMIN')")`: - `GET /policies` — list all - `GET /policies/{id}` — read one - `POST /policies` — create new (non-default) - `POST /policies/{id}/set-default` — atomic promote - `PUT /teams/{teamId}/policy-override` — set or clear per-team override - `GET /teams/{teamId}/effective-policy` — cache-bypassing live read Validation errors → 400, unknown rows → 404. ## Counterpart Supabase PR [`Stirling-PDF-SaaS#298`](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/298) — seeds the same V1 default policy on the Supabase side via `20260528000002_payg_seed_default_policy.sql`. ## Tests - 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss, invalidation on event, mutation paths publishing event, error cases. - 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path + error mapping, DTO defensive-copy invariant. - 2 × `SaasJpaConfigScanTest` — reflection-based guard that `payg.repository` is in `@EnableJpaRepositories` and `payg` is in `@EntityScan`. Without this, new sub-packages can silently fail to wire at runtime — same class of bug that the #6460 review caught. Full `:saas:test` BUILD SUCCESSFUL. ## Design doc `notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the schema half landed in #6460). §9 carries the 30s-TTL trade-off note.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* Fires after a successful admin write to a {@code pricing_policy*} or {@code
|
||||
* payg_team_extensions.pricing_policy_id} row. {@link PricingPolicyService} listens and invalidates
|
||||
* its in-process cache so the writer instance reflects the change immediately. Other instances pick
|
||||
* up the change on the next 30-second TTL expiry.
|
||||
*
|
||||
* <p>{@code payload} is informational only ({@code "create:42"}, {@code "setDefault:7"}, etc.) —
|
||||
* the invalidation strategy is "blow the whole cache" regardless of what changed.
|
||||
*/
|
||||
public class PolicyChangedEvent extends ApplicationEvent {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String payload;
|
||||
|
||||
public PolicyChangedEvent(Object source, String payload) {
|
||||
super(source);
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public String getPayload() {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.PricingPolicyRepository;
|
||||
|
||||
/**
|
||||
* Read-side facade over {@link PricingPolicyRepository}. The hot-path question is "what pricing
|
||||
* policy applies to this team right now?" — answered by either the team's per-team override (via
|
||||
* {@link PaygTeamExtensions#getPricingPolicyId()}) or the row with {@code is_default = TRUE}.
|
||||
*
|
||||
* <p>Reads are cached per-{@code teamId} for {@value #CACHE_TTL_SECONDS} seconds. The TTL is the
|
||||
* correctness floor: a policy change is visible on every instance within that window without any
|
||||
* coordination. Admin writes additionally fire a {@link PolicyChangedEvent} after commit so the
|
||||
* instance handling the write sees its own change immediately; other instances pick it up on the
|
||||
* next TTL expiry.
|
||||
*
|
||||
* <p><b>Writes are transactional and publish a {@link PolicyChangedEvent} after commit.</b> The
|
||||
* after-commit timing matters: publishing inside the tx would clear caches on instances that
|
||||
* haven't yet seen the row change, racing them into re-reading stale state. After-commit (via
|
||||
* {@link TransactionSynchronizationManager}) guarantees the new state is visible before any
|
||||
* listener fires.
|
||||
*
|
||||
* <p><b>Cache value is a JPA entity.</b> Callers must not mutate the returned policy — treat as
|
||||
* read-only. We accept this rather than wrapping in a DTO to keep the PR small; if mutation becomes
|
||||
* a footgun, swap the cache value type for an immutable snapshot.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PricingPolicyService {
|
||||
|
||||
static final int CACHE_TTL_SECONDS = 30;
|
||||
private static final int CACHE_MAX_SIZE = 10_000;
|
||||
|
||||
private final PricingPolicyRepository policyRepository;
|
||||
private final PaygTeamExtensionsRepository teamExtensionsRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* Cache keyed by {@code teamId}. Null teamId not supported (caller's bug). Value is the
|
||||
* effective policy — either the team's override or the default row.
|
||||
*/
|
||||
private final Cache<Long, PricingPolicy> byTeamCache;
|
||||
|
||||
public PricingPolicyService(
|
||||
PricingPolicyRepository policyRepository,
|
||||
PaygTeamExtensionsRepository teamExtensionsRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.policyRepository = Objects.requireNonNull(policyRepository, "policyRepository");
|
||||
this.teamExtensionsRepository =
|
||||
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
|
||||
this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher");
|
||||
this.byTeamCache =
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(CACHE_MAX_SIZE)
|
||||
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
|
||||
.recordStats()
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective policy for {@code teamId}: per-team override if set, else the row with
|
||||
* {@code is_default = TRUE}. Throws {@link IllegalStateException} if no default exists — the
|
||||
* seed migration is expected to put one there.
|
||||
*
|
||||
* <p>{@link Transactional}({@code readOnly = true}) so the eager-loaded {@code stepLimits} and
|
||||
* {@code stripePriceIds} collections initialize inside the same session.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PricingPolicy getEffectivePolicy(Long teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return byTeamCache.get(teamId, this::loadEffectivePolicy);
|
||||
}
|
||||
|
||||
/** Bypasses the cache. Useful for admin endpoints that want a fresh read after a mutation. */
|
||||
@Transactional(readOnly = true)
|
||||
public PricingPolicy getEffectivePolicyUncached(Long teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return loadEffectivePolicy(teamId);
|
||||
}
|
||||
|
||||
/** Lists every policy (admin read). Not cached — admin pages should always see fresh state. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<PricingPolicy> listAll() {
|
||||
return policyRepository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<PricingPolicy> findByVersion(String version) {
|
||||
return policyRepository.findByVersion(version);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<PricingPolicy> findById(Long policyId) {
|
||||
return policyRepository.findById(policyId);
|
||||
}
|
||||
|
||||
/** Creates a new policy row. Publishes {@link PolicyChangedEvent} after commit. */
|
||||
@Transactional
|
||||
public PricingPolicy create(PricingPolicy draft) {
|
||||
Objects.requireNonNull(draft, "draft");
|
||||
if (draft.getId() != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Create draft must not carry a policy_id; use update() to modify an existing"
|
||||
+ " row.");
|
||||
}
|
||||
if (Boolean.TRUE.equals(draft.getIsDefault())) {
|
||||
// Promotion to default must go through setDefault() so the existing default is
|
||||
// atomically cleared first; otherwise the partial unique index rejects the insert.
|
||||
throw new IllegalArgumentException(
|
||||
"Create with is_default=true is not allowed; create the row then call"
|
||||
+ " setDefault(id).");
|
||||
}
|
||||
PricingPolicy saved = policyRepository.save(draft);
|
||||
publishOnCommit("create:" + saved.getId());
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes {@code newDefaultId} to be the default policy, atomically clearing the existing
|
||||
* default first. Idempotent — calling with a row already flagged default is a silent no-op (no
|
||||
* event fired; no state actually changed).
|
||||
*/
|
||||
@Transactional
|
||||
public PricingPolicy setDefault(Long newDefaultId) {
|
||||
Objects.requireNonNull(newDefaultId, "newDefaultId");
|
||||
PricingPolicy target =
|
||||
policyRepository
|
||||
.findById(newDefaultId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"No pricing_policy with id " + newDefaultId));
|
||||
if (Boolean.TRUE.equals(target.getIsDefault())) {
|
||||
return target;
|
||||
}
|
||||
policyRepository.clearDefaultFlag();
|
||||
target.setIsDefault(true);
|
||||
PricingPolicy saved = policyRepository.save(target);
|
||||
publishOnCommit("setDefault:" + saved.getId());
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets {@code teamId}'s per-team policy override. {@code policyId = null} clears the override
|
||||
* (team falls back to default). Validates the policy exists.
|
||||
*/
|
||||
@Transactional
|
||||
public void setTeamOverride(Long teamId, Long policyId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
if (policyId != null && !policyRepository.existsById(policyId)) {
|
||||
throw new IllegalArgumentException("No pricing_policy with id " + policyId);
|
||||
}
|
||||
PaygTeamExtensions extensions =
|
||||
teamExtensionsRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No payg_team_extensions row for team "
|
||||
+ teamId
|
||||
+ " — should have been created on first"
|
||||
+ " PAYG access."));
|
||||
extensions.setPricingPolicyId(policyId);
|
||||
teamExtensionsRepository.save(extensions);
|
||||
publishOnCommit("teamOverride:" + teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the cache. Called on every {@link PolicyChangedEvent} regardless of which row
|
||||
* changed — cache hit rate is already team-scoped so the cost of a clear is bounded by how many
|
||||
* active teams there are.
|
||||
*/
|
||||
@EventListener
|
||||
public void onPolicyChanged(PolicyChangedEvent event) {
|
||||
long evicted = byTeamCache.estimatedSize();
|
||||
byTeamCache.invalidateAll();
|
||||
log.debug(
|
||||
"PricingPolicyService cache invalidated (payload='{}', approx {} entries dropped)",
|
||||
event.getPayload(),
|
||||
evicted);
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
long cacheSize() {
|
||||
return byTeamCache.estimatedSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a {@link PolicyChangedEvent} to fire after the current transaction commits, or
|
||||
* fires immediately if no transaction is active (e.g. test paths calling write methods without
|
||||
* a tx). Inside-transaction firing would have listeners clearing caches before the row change
|
||||
* is visible to other connections — racing them into re-reading stale state.
|
||||
*/
|
||||
private void publishOnCommit(String payload) {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
eventPublisher.publishEvent(
|
||||
new PolicyChangedEvent(PricingPolicyService.this, payload));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
eventPublisher.publishEvent(new PolicyChangedEvent(this, payload));
|
||||
}
|
||||
}
|
||||
|
||||
private PricingPolicy loadEffectivePolicy(Long teamId) {
|
||||
Optional<Long> overrideId =
|
||||
teamExtensionsRepository
|
||||
.findById(teamId)
|
||||
.map(PaygTeamExtensions::getPricingPolicyId);
|
||||
if (overrideId.isPresent()) {
|
||||
Long id = overrideId.get();
|
||||
Optional<PricingPolicy> override = policyRepository.findById(id);
|
||||
if (override.isPresent()) {
|
||||
return override.get();
|
||||
}
|
||||
// Override points at a missing policy — log and fall through to default rather than
|
||||
// failing hard. The admin path that sets the override should validate up front; this
|
||||
// is a safety net for racing deletes.
|
||||
log.warn(
|
||||
"Team {} has pricing_policy_id={} set as override but that row is missing;"
|
||||
+ " falling back to default.",
|
||||
teamId,
|
||||
id);
|
||||
}
|
||||
return policyRepository
|
||||
.findFirstByIsDefaultTrue()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No default pricing_policy row found — the V11 seed"
|
||||
+ " migration must run before"
|
||||
+ " PricingPolicyService is reachable."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package stirling.software.saas.payg.policy.admin;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
* Request/response DTOs for the pricing-policy admin endpoints. Records rather than the JPA entity
|
||||
* directly so the admin API surface is decoupled from internal columns (e.g. {@code @Version}
|
||||
* optimistic-lock fields, audit timestamps).
|
||||
*/
|
||||
final class PolicyDtos {
|
||||
|
||||
private PolicyDtos() {}
|
||||
|
||||
/** Outbound representation of a {@link PricingPolicy}. */
|
||||
record PolicyResponse(
|
||||
Long policyId,
|
||||
String version,
|
||||
LocalDateTime effectiveFrom,
|
||||
LocalDateTime effectiveTo,
|
||||
Integer docPagesPerUnit,
|
||||
Long docBytesPerUnit,
|
||||
Integer minChargeUnits,
|
||||
Integer fileUnitCap,
|
||||
Map<JobSource, Integer> stepLimits,
|
||||
Set<String> stripePriceIds,
|
||||
Boolean isDefault,
|
||||
String notes,
|
||||
String createdBy,
|
||||
LocalDateTime createdAt) {
|
||||
|
||||
static PolicyResponse from(PricingPolicy p) {
|
||||
return new PolicyResponse(
|
||||
p.getId(),
|
||||
p.getVersion(),
|
||||
p.getEffectiveFrom(),
|
||||
p.getEffectiveTo(),
|
||||
p.getDocPagesPerUnit(),
|
||||
p.getDocBytesPerUnit(),
|
||||
p.getMinChargeUnits(),
|
||||
p.getFileUnitCap(),
|
||||
// Copy the outer collections so a caller's mutation can't leak back into the
|
||||
// cached entity. Values (Integer, String) are immutable, so a shallow copy is
|
||||
// sufficient here.
|
||||
new HashMap<>(p.getStepLimits()),
|
||||
new HashSet<>(p.getStripePriceIds()),
|
||||
p.getIsDefault(),
|
||||
p.getNotes(),
|
||||
p.getCreatedBy(),
|
||||
p.getCreatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound payload for {@code POST /policies}. {@code stepLimits} and {@code stripePriceIds}
|
||||
* default to empty collections if omitted. {@code effectiveFrom} defaults to {@code now()}.
|
||||
*/
|
||||
record CreatePolicyRequest(
|
||||
String version,
|
||||
LocalDateTime effectiveFrom,
|
||||
LocalDateTime effectiveTo,
|
||||
Integer docPagesPerUnit,
|
||||
Long docBytesPerUnit,
|
||||
Integer minChargeUnits,
|
||||
Integer fileUnitCap,
|
||||
Map<JobSource, Integer> stepLimits,
|
||||
Set<String> stripePriceIds,
|
||||
String notes,
|
||||
String createdBy) {}
|
||||
|
||||
/**
|
||||
* Inbound payload for {@code PUT /teams/{teamId}/policy-override}. {@code policyId = null}
|
||||
* clears the override (team falls back to default).
|
||||
*/
|
||||
record TeamOverrideRequest(Long policyId) {}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package stirling.software.saas.payg.policy.admin;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
|
||||
|
||||
/**
|
||||
* Admin-only CRUD for {@link PricingPolicy} rows + per-team override + default-promotion. Every
|
||||
* mutation routes through {@link PricingPolicyService} so the cache invalidation event is published
|
||||
* exactly once per mutation, after commit. Reads return live data (no cache) so admins always see
|
||||
* their own write.
|
||||
*
|
||||
* <p>Path namespace {@code /api/v1/admin/payg/...} matches the design's other admin endpoints
|
||||
* (cap-setting, cohort migration). Every endpoint requires {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/payg")
|
||||
@Profile("saas")
|
||||
@Tag(name = "PAYG Admin — Pricing Policy", description = "Admin CRUD for pricing policies")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PricingPolicyAdminController {
|
||||
|
||||
private final PricingPolicyService policyService;
|
||||
|
||||
@GetMapping("/policies")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List all pricing policies (admin)")
|
||||
public ResponseEntity<List<PolicyResponse>> listPolicies() {
|
||||
return ResponseEntity.ok(
|
||||
policyService.listAll().stream().map(PolicyResponse::from).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/policies/{policyId}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get a single pricing policy by id (admin)")
|
||||
public ResponseEntity<PolicyResponse> getPolicy(@PathVariable Long policyId) {
|
||||
return policyService
|
||||
.findById(policyId)
|
||||
.map(p -> ResponseEntity.ok(PolicyResponse.from(p)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/policies")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Create a new pricing policy (admin)",
|
||||
description =
|
||||
"Creates a non-default policy. To promote to default, call set-default after"
|
||||
+ " creation.")
|
||||
public ResponseEntity<?> createPolicy(@RequestBody CreatePolicyRequest req) {
|
||||
try {
|
||||
PricingPolicy draft = mapCreateRequest(req);
|
||||
PricingPolicy saved = policyService.create(draft);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(PolicyResponse.from(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/policies/{policyId}/set-default")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Promote a policy to default (admin)",
|
||||
description =
|
||||
"Atomically clears the existing default flag and sets this row's flag."
|
||||
+ " Teams without an override use the default.")
|
||||
public ResponseEntity<?> setDefault(@PathVariable Long policyId) {
|
||||
try {
|
||||
PricingPolicy promoted = policyService.setDefault(policyId);
|
||||
return ResponseEntity.ok(PolicyResponse.from(promoted));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/teams/{teamId}/policy-override")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Set or clear a team's per-team pricing-policy override (admin)",
|
||||
description =
|
||||
"Payload {policyId: <id>} sets the override; {policyId: null} clears it"
|
||||
+ " (team falls back to default).")
|
||||
public ResponseEntity<?> setTeamOverride(
|
||||
@PathVariable Long teamId, @RequestBody TeamOverrideRequest req) {
|
||||
try {
|
||||
policyService.setTeamOverride(teamId, req == null ? null : req.policyId());
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
HttpStatus status =
|
||||
e instanceof IllegalStateException
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
return ResponseEntity.status(status).body(error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/teams/{teamId}/effective-policy")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Read the effective policy for a team (admin)",
|
||||
description =
|
||||
"Returns the override if set, else the default. Bypasses the read cache so"
|
||||
+ " admins always see the latest state.")
|
||||
public ResponseEntity<PolicyResponse> getEffectivePolicy(@PathVariable Long teamId) {
|
||||
return ResponseEntity.ok(
|
||||
PolicyResponse.from(policyService.getEffectivePolicyUncached(teamId)));
|
||||
}
|
||||
|
||||
private static PricingPolicy mapCreateRequest(CreatePolicyRequest req) {
|
||||
if (req == null) {
|
||||
throw new IllegalArgumentException("Request body required.");
|
||||
}
|
||||
if (req.version() == null || req.version().isBlank()) {
|
||||
throw new IllegalArgumentException("version is required.");
|
||||
}
|
||||
if (req.docPagesPerUnit() == null || req.docBytesPerUnit() == null) {
|
||||
throw new IllegalArgumentException("docPagesPerUnit and docBytesPerUnit are required.");
|
||||
}
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setVersion(req.version());
|
||||
p.setEffectiveFrom(req.effectiveFrom() != null ? req.effectiveFrom() : LocalDateTime.now());
|
||||
p.setEffectiveTo(req.effectiveTo());
|
||||
p.setDocPagesPerUnit(req.docPagesPerUnit());
|
||||
p.setDocBytesPerUnit(req.docBytesPerUnit());
|
||||
p.setMinChargeUnits(req.minChargeUnits() != null ? req.minChargeUnits() : 1);
|
||||
p.setFileUnitCap(req.fileUnitCap() != null ? req.fileUnitCap() : 1000);
|
||||
p.setStepLimits(
|
||||
req.stepLimits() != null ? new HashMap<>(req.stepLimits()) : new HashMap<>());
|
||||
p.setStripePriceIds(
|
||||
req.stripePriceIds() != null
|
||||
? new HashSet<>(req.stripePriceIds())
|
||||
: new HashSet<>());
|
||||
p.setIsDefault(false);
|
||||
p.setNotes(req.notes());
|
||||
p.setCreatedBy(req.createdBy());
|
||||
return p;
|
||||
}
|
||||
|
||||
private static java.util.Map<String, String> error(String message) {
|
||||
return java.util.Map.of("error", message == null ? "unknown" : message);
|
||||
}
|
||||
}
|
||||
+13
@@ -3,6 +3,8 @@ package stirling.software.saas.payg.repository;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
@@ -13,4 +15,15 @@ public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Lo
|
||||
Optional<PricingPolicy> findByVersion(String version);
|
||||
|
||||
Optional<PricingPolicy> findFirstByIsDefaultTrue();
|
||||
|
||||
/**
|
||||
* Atomically clears the {@code is_default} flag on whichever row currently carries it. Used by
|
||||
* {@code setDefault(newId)} to free the slot before flipping the new row's flag — the {@code
|
||||
* uq_pricing_policy_default} partial unique index would otherwise reject the second row.
|
||||
*
|
||||
* <p>Returns the count of rows updated (0 if no default existed yet, 1 normally).
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE PricingPolicy p SET p.isDefault = false WHERE p.isDefault = true")
|
||||
int clearDefaultFlag();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists.
|
||||
-- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once
|
||||
-- Stripe Prices are wired in production.
|
||||
--
|
||||
-- This migration is separated from V11 because V11 has already shipped to main — adding rows to
|
||||
-- it would change its Flyway checksum and break existing deployments.
|
||||
|
||||
INSERT INTO pricing_policy (
|
||||
version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
|
||||
min_charge_units, file_unit_cap, is_default, notes, created_by
|
||||
)
|
||||
SELECT
|
||||
'v1-initial', CURRENT_TIMESTAMP, 25, 5242880,
|
||||
1, 1000, TRUE,
|
||||
'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM pricing_policy WHERE is_default = TRUE
|
||||
);
|
||||
|
||||
-- Step limits for the default policy across every JobSource. References the row inserted above
|
||||
-- via the partial unique index on is_default=TRUE.
|
||||
INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit)
|
||||
SELECT p.policy_id, src.job_source, src.step_limit
|
||||
FROM pricing_policy p
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('WEB', 10),
|
||||
('API', 10),
|
||||
('PIPELINE', 20), -- automations get a longer chain
|
||||
('DESKTOP_APP', 10)
|
||||
) AS src(job_source, step_limit)
|
||||
WHERE p.is_default = TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pricing_policy_step_limit s
|
||||
WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source
|
||||
);
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
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.atLeastOnce;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.PricingPolicyRepository;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PricingPolicyService}: lookup precedence (override → default), cache
|
||||
* hit/miss, invalidation on {@link PolicyChangedEvent}, mutation paths publishing the event.
|
||||
*/
|
||||
class PricingPolicyServiceTest {
|
||||
|
||||
private PricingPolicyRepository policyRepo;
|
||||
private PaygTeamExtensionsRepository extensionsRepo;
|
||||
private ApplicationEventPublisher events;
|
||||
private PricingPolicyService service;
|
||||
|
||||
private PricingPolicy defaultPolicy;
|
||||
private PricingPolicy overridePolicy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
policyRepo = Mockito.mock(PricingPolicyRepository.class);
|
||||
extensionsRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
|
||||
events = Mockito.mock(ApplicationEventPublisher.class);
|
||||
service = new PricingPolicyService(policyRepo, extensionsRepo, events);
|
||||
|
||||
defaultPolicy = policy(1L, "v1-default", true);
|
||||
overridePolicy = policy(2L, "v1-enterprise", false);
|
||||
|
||||
when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.of(defaultPolicy));
|
||||
when(policyRepo.findById(1L)).thenReturn(Optional.of(defaultPolicy));
|
||||
when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
|
||||
when(policyRepo.existsById(2L)).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOverride_returnsDefault() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
PricingPolicy result = service.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(result).isEqualTo(defaultPolicy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideSet_returnsOverride() {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
ext.setPricingPolicyId(2L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
|
||||
PricingPolicy result = service.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(result).isEqualTo(overridePolicy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overridePointsAtMissingPolicy_fallsBackToDefault() {
|
||||
// Race condition: team's override row references a policy that has since been deleted.
|
||||
// Service should log + fall back rather than throw, so the team still gets billed
|
||||
// correctly under the default.
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
ext.setPricingPolicyId(999L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
when(policyRepo.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
PricingPolicy result = service.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(result).isEqualTo(defaultPolicy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDefaultExists_throws() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.getEffectivePolicy(42L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("No default pricing_policy row");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCallHitsCache_noRepoLookup() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
service.getEffectivePolicy(42L);
|
||||
service.getEffectivePolicy(42L);
|
||||
service.getEffectivePolicy(42L);
|
||||
|
||||
// Three calls, one DB lookup — the cache holds the result.
|
||||
verify(policyRepo, times(1)).findFirstByIsDefaultTrue();
|
||||
verify(extensionsRepo, times(1)).findById(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncachedRead_alwaysHitsRepo() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
service.getEffectivePolicyUncached(42L);
|
||||
service.getEffectivePolicyUncached(42L);
|
||||
|
||||
verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyChangedEvent_invalidatesCache() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
service.getEffectivePolicy(42L);
|
||||
assertThat(service.cacheSize()).isEqualTo(1);
|
||||
|
||||
service.onPolicyChanged(new PolicyChangedEvent(this, "test"));
|
||||
|
||||
assertThat(service.cacheSize()).isZero();
|
||||
// Next call repopulates from DB.
|
||||
service.getEffectivePolicy(42L);
|
||||
verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejectsDraftWithId() {
|
||||
PricingPolicy draft = policy(99L, "v2", false);
|
||||
assertThatThrownBy(() -> service.create(draft))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("must not carry a policy_id");
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejectsDefaultFlagPreSet() {
|
||||
PricingPolicy draft = policy(null, "v2", true);
|
||||
assertThatThrownBy(() -> service.create(draft))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("setDefault");
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_savesAndPublishesEvent() {
|
||||
PricingPolicy draft = policy(null, "v2-fresh", false);
|
||||
PricingPolicy saved = policy(3L, "v2-fresh", false);
|
||||
when(policyRepo.save(draft)).thenReturn(saved);
|
||||
|
||||
PricingPolicy result = service.create(draft);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
ArgumentCaptor<PolicyChangedEvent> evt = ArgumentCaptor.forClass(PolicyChangedEvent.class);
|
||||
verify(events).publishEvent(evt.capture());
|
||||
assertThat(evt.getValue().getPayload()).contains("create:3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_promotesAndClearsExisting() {
|
||||
// newDefaultId = 2, current default is 1
|
||||
PricingPolicy promoted = policy(2L, "v1-enterprise", true);
|
||||
when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
|
||||
when(policyRepo.save(any(PricingPolicy.class))).thenReturn(promoted);
|
||||
|
||||
PricingPolicy result = service.setDefault(2L);
|
||||
|
||||
verify(policyRepo).clearDefaultFlag();
|
||||
assertThat(result.getIsDefault()).isTrue();
|
||||
verify(events, atLeastOnce()).publishEvent(any(PolicyChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_alreadyDefault_isNoop() {
|
||||
// Calling setDefault on the row that's already default → return it, don't re-flag, but
|
||||
// still don't fire an event (no state change). Keeps callers idempotent without spamming
|
||||
// listeners.
|
||||
PricingPolicy result = service.setDefault(1L);
|
||||
|
||||
assertThat(result).isEqualTo(defaultPolicy);
|
||||
verify(policyRepo, never()).clearDefaultFlag();
|
||||
verify(policyRepo, never()).save(any(PricingPolicy.class));
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_unknownId_throws() {
|
||||
when(policyRepo.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.setDefault(999L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("No pricing_policy with id 999");
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_setsAndPublishes() {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
|
||||
|
||||
service.setTeamOverride(42L, 2L);
|
||||
|
||||
assertThat(ext.getPricingPolicyId()).isEqualTo(2L);
|
||||
verify(extensionsRepo).save(ext);
|
||||
verify(events).publishEvent(any(PolicyChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_clearsWithNullPolicyId() {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
ext.setPricingPolicyId(2L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
|
||||
|
||||
service.setTeamOverride(42L, null);
|
||||
|
||||
assertThat(ext.getPricingPolicyId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_unknownPolicyId_throwsBeforeSave() {
|
||||
when(policyRepo.existsById(999L)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.setTeamOverride(42L, 999L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("No pricing_policy with id 999");
|
||||
|
||||
verify(extensionsRepo, never()).save(any(PaygTeamExtensions.class));
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_missingExtensionsRow_throws() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.setTeamOverride(42L, 2L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("payg_team_extensions row");
|
||||
}
|
||||
|
||||
private static PricingPolicy policy(Long id, String version, boolean isDefault) {
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setId(id);
|
||||
p.setVersion(version);
|
||||
p.setEffectiveFrom(LocalDateTime.now());
|
||||
p.setDocPagesPerUnit(25);
|
||||
p.setDocBytesPerUnit(5L * 1024 * 1024);
|
||||
p.setMinChargeUnits(1);
|
||||
p.setFileUnitCap(1000);
|
||||
p.setIsDefault(isDefault);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package stirling.software.saas.payg.policy.admin;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
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 stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
|
||||
|
||||
/**
|
||||
* Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
|
||||
* CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
|
||||
* error mapping (4xx for validation, 404 for missing rows).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PricingPolicyAdminControllerTest {
|
||||
|
||||
@Mock private PricingPolicyService service;
|
||||
|
||||
private PricingPolicyAdminController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new PricingPolicyAdminController(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPolicies_returnsAll() {
|
||||
when(service.listAll())
|
||||
.thenReturn(List.of(policy(1L, "v1", true), policy(2L, "v2", false)));
|
||||
|
||||
ResponseEntity<List<PolicyResponse>> resp = controller.listPolicies();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody()).hasSize(2);
|
||||
assertThat(resp.getBody().get(0).version()).isEqualTo("v1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPolicy_returnsOk() {
|
||||
when(service.findById(1L)).thenReturn(Optional.of(policy(1L, "v1", true)));
|
||||
|
||||
ResponseEntity<PolicyResponse> resp = controller.getPolicy(1L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().policyId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPolicy_missingReturns404() {
|
||||
when(service.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
ResponseEntity<PolicyResponse> resp = controller.getPolicy(999L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPolicy_happyPath() {
|
||||
CreatePolicyRequest req =
|
||||
new CreatePolicyRequest(
|
||||
"v2",
|
||||
LocalDateTime.now(),
|
||||
null,
|
||||
25,
|
||||
5L * 1024 * 1024,
|
||||
1,
|
||||
1000,
|
||||
null,
|
||||
null,
|
||||
"notes",
|
||||
"[email protected]");
|
||||
PricingPolicy saved = policy(99L, "v2", false);
|
||||
ArgumentCaptor<PricingPolicy> draft = ArgumentCaptor.forClass(PricingPolicy.class);
|
||||
when(service.create(draft.capture())).thenReturn(saved);
|
||||
|
||||
ResponseEntity<?> resp = controller.createPolicy(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
assertThat(((PolicyResponse) resp.getBody()).policyId()).isEqualTo(99L);
|
||||
assertThat(draft.getValue().getVersion()).isEqualTo("v2");
|
||||
// Controller must never let isDefault=true through to the service — setDefault is the
|
||||
// only path for promotion.
|
||||
assertThat(draft.getValue().getIsDefault()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPolicy_missingVersion_returns400() {
|
||||
CreatePolicyRequest req =
|
||||
new CreatePolicyRequest(
|
||||
null, null, null, 25, 5L * 1024 * 1024, 1, 1000, null, null, null, null);
|
||||
|
||||
ResponseEntity<?> resp = controller.createPolicy(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPolicy_missingDocFields_returns400() {
|
||||
CreatePolicyRequest req =
|
||||
new CreatePolicyRequest(
|
||||
"v2", null, null, null, null, 1, 1000, null, null, null, null);
|
||||
|
||||
ResponseEntity<?> resp = controller.createPolicy(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_returnsOk() {
|
||||
when(service.setDefault(2L)).thenReturn(policy(2L, "v2-promoted", true));
|
||||
|
||||
ResponseEntity<?> resp = controller.setDefault(2L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(((PolicyResponse) resp.getBody()).isDefault()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_unknownId_returns404() {
|
||||
when(service.setDefault(999L))
|
||||
.thenThrow(new IllegalArgumentException("No pricing_policy with id 999"));
|
||||
|
||||
ResponseEntity<?> resp = controller.setDefault(999L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_noContent() {
|
||||
TeamOverrideRequest req = new TeamOverrideRequest(2L);
|
||||
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(service).setTeamOverride(42L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_nullBody_clearsOverride() {
|
||||
// Curl with no body, or {} → req == null is handled as "clear".
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, null);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(service).setTeamOverride(42L, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_unknownPolicy_returns400() {
|
||||
TeamOverrideRequest req = new TeamOverrideRequest(999L);
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("No pricing_policy with id 999"))
|
||||
.when(service)
|
||||
.setTeamOverride(42L, 999L);
|
||||
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_missingTeamExtensions_returns404() {
|
||||
TeamOverrideRequest req = new TeamOverrideRequest(2L);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("No payg_team_extensions row"))
|
||||
.when(service)
|
||||
.setTeamOverride(42L, 2L);
|
||||
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEffectivePolicy_bypassesCache() {
|
||||
when(service.getEffectivePolicyUncached(42L)).thenReturn(policy(1L, "v1", true));
|
||||
|
||||
ResponseEntity<PolicyResponse> resp = controller.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().version()).isEqualTo("v1");
|
||||
verify(service).getEffectivePolicyUncached(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyResponse_collectionsAreDefensiveCopies() {
|
||||
PricingPolicy p = policy(1L, "v1", true);
|
||||
p.setStepLimits(new java.util.HashMap<>(Map.of()));
|
||||
p.setStripePriceIds(new java.util.HashSet<>());
|
||||
|
||||
PolicyResponse resp = PolicyResponse.from(p);
|
||||
|
||||
// Mutating the source after building the response should not affect the response.
|
||||
p.getStepLimits().put(stirling.software.saas.payg.model.JobSource.WEB, 99);
|
||||
p.getStripePriceIds().add("price_xyz");
|
||||
assertThat(resp.stepLimits()).isEmpty();
|
||||
assertThat(resp.stripePriceIds()).isEmpty();
|
||||
}
|
||||
|
||||
private static PricingPolicy policy(Long id, String version, boolean isDefault) {
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setId(id);
|
||||
p.setVersion(version);
|
||||
p.setEffectiveFrom(LocalDateTime.now());
|
||||
p.setDocPagesPerUnit(25);
|
||||
p.setDocBytesPerUnit(5L * 1024 * 1024);
|
||||
p.setMinChargeUnits(1);
|
||||
p.setFileUnitCap(1000);
|
||||
p.setIsDefault(isDefault);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user