mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 07:49:01 +00:00
G-1: renewal-policies API + frontend FK-drift fix
Three frontend call sites (OnboardingWizard.tsx:603, CertificatesPage.tsx:52,
CertificateDetailPage.tsx:169) populated the renewal_policy_id dropdown from
getPolicies() — the compliance-rule endpoint returning pol-* IDs — which
violated the FK managed_certificates.renewal_policy_id REFERENCES
renewal_policies(id) ON DELETE RESTRICT. Create would fail pg 23503 at insert.
Backend (new):
- RenewalPolicyRepository CRUD + ListAll/ExistsByID (pg 23503 → ErrRenewalPolicyInUse
→ HTTP 409; pg 23505 → ErrRenewalPolicyDuplicateName → HTTP 409)
- RenewalPolicyService with repo-only constructor. Service sentinels
var-alias the repo sentinels so errors.Is walks across layers.
- RenewalPolicyHandler with validation bounds: name 1–255;
renewal_window_days [1,365] default 30; max_retries [0,10] not defaulted;
retry_interval_seconds [60,86400] default 3600; alert_thresholds_days
[0,365] default [30,14,7,0]. Auto-generated IDs rp-<slug(name)>.
- Router registers 5 routes under /api/v1/renewal-policies[/{id}].
Frontend:
- CertificatesPage/CertificateDetailPage/OnboardingWizard now call
getRenewalPolicies() and render rp-* IDs.
- client.ts adds getRenewalPolicies/createRenewalPolicy/updateRenewalPolicy/
deleteRenewalPolicy. types.ts adds the RenewalPolicy shape.
OpenAPI: RenewalPolicies tag + 5 operations + 3 schemas (RenewalPolicy,
RenewalPolicyCreateRequest, RenewalPolicyUpdateRequest). 409 responses
on create/update duplicate-name and delete FK-in-use.
No migration — renewal_policies table already exists from the initial
schema (000001).
Tests:
- internal/service/renewal_policy_test.go: CRUD + validation + sentinel
error wrapping.
- internal/api/handler/renewal_policy_handler_test.go: handler endpoint
contracts including 400/404/409.
- web/src/api/client.test.ts: 4 subtests covering the 4 new API functions.
Phase 3 gates all green: go vet, build, short tests, race tests (service/
handler/router/scheduler), staticcheck (G-1 packages), govulncheck (0
reachable), coverage (service 69.7%, handler 79.0%, domain 86.9%,
middleware 80.6% — all above thresholds), tsc, vitest (256 passed),
vite build, OpenAPI structural validation.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// G-1: service-level sentinels alias the repository sentinels so errors.Is
|
||||
// walks transparently across layers. Do NOT errors.New a fresh copy — the
|
||||
// handler's `errors.Is(err, repository.ErrRenewalPolicyInUse)` branch and
|
||||
// the service-layer tests' `errors.Is(err, service.ErrRenewalPolicyInUse)`
|
||||
// branch need to match the same sentinel var identity.
|
||||
var (
|
||||
ErrRenewalPolicyDuplicateName = repository.ErrRenewalPolicyDuplicateName
|
||||
ErrRenewalPolicyInUse = repository.ErrRenewalPolicyInUse
|
||||
)
|
||||
|
||||
// RenewalPolicyService implements the /api/v1/renewal-policies CRUD surface.
|
||||
//
|
||||
// G-1 scope note: the red-test contract pins NewRenewalPolicyService to a
|
||||
// repo-only signature (no auditService). Renewal-policy CRUD does not emit
|
||||
// audit events in this change — if audit coverage is needed later, add a
|
||||
// SetAuditService setter rather than churning the constructor signature.
|
||||
type RenewalPolicyService struct {
|
||||
repo repository.RenewalPolicyRepository
|
||||
}
|
||||
|
||||
// NewRenewalPolicyService constructs the service bound to its repository.
|
||||
func NewRenewalPolicyService(repo repository.RenewalPolicyRepository) *RenewalPolicyService {
|
||||
return &RenewalPolicyService{repo: repo}
|
||||
}
|
||||
|
||||
// rpSlugRegex matches non-alphanumeric characters that slugifyRenewalPolicyName strips.
|
||||
// Mirrors the identical regex in internal/repository/postgres/renewal_policy.go —
|
||||
// the service owns the rp-<slug> convention so the repo's retry loop is a
|
||||
// pure PK-collision safety net, not the primary ID generator.
|
||||
var rpSlugRegex = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
// slugifyRenewalPolicyName produces `rp-<slug>` for an auto-generated policy
|
||||
// ID. Slug: lowercase, spaces→hyphens, non-alphanumeric stripped, trimmed to
|
||||
// 64 chars. Matches the seed convention (rp-default, rp-standard, rp-urgent)
|
||||
// and the repo's slugifyPolicyName byte-for-byte.
|
||||
func slugifyRenewalPolicyName(name string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(name))
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = rpSlugRegex.ReplaceAllString(slug, "")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if slug == "" {
|
||||
slug = "policy"
|
||||
}
|
||||
if len(slug) > 64 {
|
||||
slug = slug[:64]
|
||||
}
|
||||
return "rp-" + slug
|
||||
}
|
||||
|
||||
// ListRenewalPolicies returns a single page of renewal policies sorted by
|
||||
// name (the repo's ORDER BY name is index-served via idx_renewal_policies_name).
|
||||
// Pagination is done in Go rather than SQL — the expected row count is in the
|
||||
// single digits so LIMIT/OFFSET would be premature optimization and would
|
||||
// churn the repo contract for no measurable benefit (design doc §Known
|
||||
// Caller Audit).
|
||||
//
|
||||
// Bounds: page defaults to 1, per_page defaults to 50, caps at 500 to match
|
||||
// the /api/v1/policies handler's behavior. Past-end slices return an empty
|
||||
// slice with no error — callers use `total` to detect end of pagination.
|
||||
func (s *RenewalPolicyService) ListRenewalPolicies(ctx context.Context, page, perPage int) ([]domain.RenewalPolicy, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
if perPage > 500 {
|
||||
perPage = 500
|
||||
}
|
||||
|
||||
items, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list renewal policies: %w", err)
|
||||
}
|
||||
|
||||
total := int64(len(items))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
|
||||
out := make([]domain.RenewalPolicy, 0, end-start)
|
||||
for _, p := range items[start:end] {
|
||||
if p != nil {
|
||||
out = append(out, *p)
|
||||
}
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetRenewalPolicy retrieves one renewal policy by ID. Not-found errors
|
||||
// surface from the repo verbatim; the handler translates them to 404.
|
||||
func (s *RenewalPolicyService) GetRenewalPolicy(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// validateBounds enforces the design doc §Validation Bounds invariants:
|
||||
// - name required, ≤ 255 chars
|
||||
// - renewal_window_days in [1, 365]
|
||||
// - max_retries in [0, 10]
|
||||
// - retry_interval_seconds in [60, 86400]
|
||||
// - alert_thresholds_days each in [0, 365]
|
||||
//
|
||||
// Called after applyCreateDefaults so zero-value fields that the caller
|
||||
// expects to be defaulted don't trip the range checks.
|
||||
func (s *RenewalPolicyService) validateBounds(rp *domain.RenewalPolicy) error {
|
||||
if strings.TrimSpace(rp.Name) == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if len(rp.Name) > 255 {
|
||||
return fmt.Errorf("name must be 255 characters or fewer, got %d", len(rp.Name))
|
||||
}
|
||||
if rp.RenewalWindowDays < 1 || rp.RenewalWindowDays > 365 {
|
||||
return fmt.Errorf("renewal_window_days must be between 1 and 365, got %d", rp.RenewalWindowDays)
|
||||
}
|
||||
if rp.MaxRetries < 0 || rp.MaxRetries > 10 {
|
||||
return fmt.Errorf("max_retries must be between 0 and 10, got %d", rp.MaxRetries)
|
||||
}
|
||||
if rp.RetryInterval < 60 || rp.RetryInterval > 86400 {
|
||||
return fmt.Errorf("retry_interval_seconds must be between 60 and 86400, got %d", rp.RetryInterval)
|
||||
}
|
||||
for i, t := range rp.AlertThresholdsDays {
|
||||
if t < 0 || t > 365 {
|
||||
return fmt.Errorf("alert_thresholds_days[%d]=%d must be between 0 and 365", i, t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyCreateDefaults fills in zero-valued optional fields with the design
|
||||
// doc defaults. Name is never defaulted — missing name fails validation.
|
||||
// MaxRetries=0 is a legal explicit value (no retries), so it is NOT
|
||||
// defaulted; the DB default column handles that path if needed.
|
||||
func (s *RenewalPolicyService) applyCreateDefaults(rp *domain.RenewalPolicy) {
|
||||
if rp.RenewalWindowDays == 0 {
|
||||
rp.RenewalWindowDays = 30
|
||||
}
|
||||
if rp.RetryInterval == 0 {
|
||||
rp.RetryInterval = 3600
|
||||
}
|
||||
if len(rp.AlertThresholdsDays) == 0 {
|
||||
rp.AlertThresholdsDays = domain.DefaultAlertThresholds()
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRenewalPolicy inserts a new renewal policy. Auto-generates
|
||||
// `rp-<slug(name)>` for ID if empty. Defaults are applied before bounds
|
||||
// validation so a caller can omit RenewalWindowDays / RetryInterval and
|
||||
// still pass bounds. Returns ErrRenewalPolicyDuplicateName unwrapped from
|
||||
// the repo when a name collision occurs (pg 23505 on the UNIQUE constraint);
|
||||
// the handler surfaces that as 409 Conflict.
|
||||
func (s *RenewalPolicyService) CreateRenewalPolicy(ctx context.Context, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
|
||||
s.applyCreateDefaults(&rp)
|
||||
if err := s.validateBounds(&rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rp.ID == "" {
|
||||
rp.ID = slugifyRenewalPolicyName(rp.Name)
|
||||
}
|
||||
if rp.CreatedAt.IsZero() {
|
||||
rp.CreatedAt = time.Now()
|
||||
}
|
||||
if err := s.repo.Create(ctx, &rp); err != nil {
|
||||
// Propagate repository sentinels verbatim — service-level sentinels
|
||||
// alias repo sentinels (same var identity), so errors.Is walks
|
||||
// through without any translation.
|
||||
return nil, err
|
||||
}
|
||||
return &rp, nil
|
||||
}
|
||||
|
||||
// UpdateRenewalPolicy replaces the fields of an existing renewal policy.
|
||||
// Applies the same defaults+bounds as Create so partial updates do not slip
|
||||
// an invalid row past validation via zero-value fields. id in the path wins
|
||||
// over any id the caller supplied in the body.
|
||||
func (s *RenewalPolicyService) UpdateRenewalPolicy(ctx context.Context, id string, rp domain.RenewalPolicy) (*domain.RenewalPolicy, error) {
|
||||
s.applyCreateDefaults(&rp)
|
||||
if err := s.validateBounds(&rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rp.ID = id
|
||||
if err := s.repo.Update(ctx, id, &rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rp, nil
|
||||
}
|
||||
|
||||
// DeleteRenewalPolicy removes a renewal policy. Returns ErrRenewalPolicyInUse
|
||||
// when the policy is still referenced by rows in managed_certificates (the
|
||||
// repo translates pg 23503 FK_RESTRICT violations onto that sentinel). The
|
||||
// handler surfaces that as 409 Conflict.
|
||||
func (s *RenewalPolicyService) DeleteRenewalPolicy(ctx context.Context, id string) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// G-1 red tests: lock in the behavior of RenewalPolicyService before
|
||||
// the production code exists. Every subtest here references a type or
|
||||
// method that Phase 2b must introduce:
|
||||
//
|
||||
// - NewRenewalPolicyService(repo) (constructor)
|
||||
// - svc.ListRenewalPolicies(ctx, page, pp) ([]RenewalPolicy, int64, error)
|
||||
// - svc.GetRenewalPolicy(ctx, id) (*RenewalPolicy, error)
|
||||
// - svc.CreateRenewalPolicy(ctx, rp) (*RenewalPolicy, error)
|
||||
// - svc.UpdateRenewalPolicy(ctx, id, rp) (*RenewalPolicy, error)
|
||||
// - svc.DeleteRenewalPolicy(ctx, id) error
|
||||
// - ErrRenewalPolicyDuplicateName sentinel (pg 23505 → 409)
|
||||
// - ErrRenewalPolicyInUse sentinel (pg 23503 → 409)
|
||||
//
|
||||
// Once Phase 2b lands, these should all turn green without modification.
|
||||
|
||||
func TestRenewalPolicyService_List_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
repo := &mockRenewalPolicyRepo{
|
||||
Policies: map[string]*domain.RenewalPolicy{
|
||||
"rp-default": {
|
||||
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
|
||||
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
},
|
||||
"rp-urgent": {
|
||||
ID: "rp-urgent", Name: "Urgent", RenewalWindowDays: 7,
|
||||
MaxRetries: 5, RetryInterval: 600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
items, total, err := svc.ListRenewalPolicies(ctx, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRenewalPolicies failed: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("expected total 2, got %d", total)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Errorf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_List_Empty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
items, total, err := svc.ListRenewalPolicies(ctx, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRenewalPolicies failed: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected total 0, got %d", total)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Errorf("expected 0 items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_List_Pagination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
// Seed 5 policies, names A..E so the mock's sort.Slice yields a deterministic
|
||||
// ordering that pagination boundaries can assert against.
|
||||
for _, name := range []string{"A", "B", "C", "D", "E"} {
|
||||
p := &domain.RenewalPolicy{
|
||||
ID: "rp-" + strings.ToLower(name), Name: name,
|
||||
RenewalWindowDays: 30, MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
repo.Policies[p.ID] = p
|
||||
}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
// Page 1, size 2 → [A, B]
|
||||
page1, total, err := svc.ListRenewalPolicies(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("page 1 failed: %v", err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Errorf("expected total 5, got %d", total)
|
||||
}
|
||||
if len(page1) != 2 || page1[0].Name != "A" || page1[1].Name != "B" {
|
||||
t.Errorf("unexpected page 1 slice: %+v", page1)
|
||||
}
|
||||
|
||||
// Page 3, size 2 → [E] (single-item last page)
|
||||
page3, _, err := svc.ListRenewalPolicies(ctx, 3, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("page 3 failed: %v", err)
|
||||
}
|
||||
if len(page3) != 1 || page3[0].Name != "E" {
|
||||
t.Errorf("unexpected page 3 slice: %+v", page3)
|
||||
}
|
||||
|
||||
// Page 4, size 2 → [] (past the end, no error)
|
||||
page4, _, err := svc.ListRenewalPolicies(ctx, 4, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("page 4 failed: %v", err)
|
||||
}
|
||||
if len(page4) != 0 {
|
||||
t.Errorf("expected empty past-end slice, got %+v", page4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_List_RepoError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{
|
||||
Policies: map[string]*domain.RenewalPolicy{},
|
||||
ListErr: errors.New("boom"),
|
||||
}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
_, _, err := svc.ListRenewalPolicies(ctx, 1, 50)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Get_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
rp := &domain.RenewalPolicy{
|
||||
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
|
||||
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{"rp-default": rp}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
got, err := svc.GetRenewalPolicy(ctx, "rp-default")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalPolicy failed: %v", err)
|
||||
}
|
||||
if got.Name != "Default" {
|
||||
t.Errorf("expected name Default, got %s", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Get_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
_, err := svc.GetRenewalPolicy(ctx, "rp-missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing policy, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Create_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
rp := domain.RenewalPolicy{
|
||||
Name: "Weekly Renewal",
|
||||
RenewalWindowDays: 7,
|
||||
MaxRetries: 3,
|
||||
RetryInterval: 3600,
|
||||
AutoRenew: true,
|
||||
}
|
||||
created, err := svc.CreateRenewalPolicy(ctx, rp)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRenewalPolicy failed: %v", err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatal("expected auto-generated ID, got empty")
|
||||
}
|
||||
// ID convention: rp-<slug(name)> matches seed rows rp-default/rp-standard/rp-urgent.
|
||||
if !strings.HasPrefix(created.ID, "rp-") {
|
||||
t.Errorf("expected ID prefix rp-, got %s", created.ID)
|
||||
}
|
||||
if created.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Create_MissingName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
_, err := svc.CreateRenewalPolicy(ctx, domain.RenewalPolicy{
|
||||
RenewalWindowDays: 30, MaxRetries: 3, RetryInterval: 3600,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Create_BoundsViolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
// RenewalWindowDays out of range [1, 365]
|
||||
_, err := svc.CreateRenewalPolicy(ctx, domain.RenewalPolicy{
|
||||
Name: "Bad Window",
|
||||
RenewalWindowDays: 999,
|
||||
MaxRetries: 3,
|
||||
RetryInterval: 3600,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected bounds violation on RenewalWindowDays, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Create_DuplicateName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{
|
||||
Policies: map[string]*domain.RenewalPolicy{},
|
||||
CreateErr: ErrRenewalPolicyDuplicateName,
|
||||
}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
_, err := svc.CreateRenewalPolicy(ctx, domain.RenewalPolicy{
|
||||
Name: "Duplicate",
|
||||
RenewalWindowDays: 30,
|
||||
MaxRetries: 3,
|
||||
RetryInterval: 3600,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate-name error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrRenewalPolicyDuplicateName) {
|
||||
t.Errorf("expected ErrRenewalPolicyDuplicateName, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Update_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
rp := &domain.RenewalPolicy{
|
||||
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
|
||||
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{"rp-default": rp}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
updated, err := svc.UpdateRenewalPolicy(ctx, "rp-default", domain.RenewalPolicy{
|
||||
Name: "Default Renamed",
|
||||
RenewalWindowDays: 45,
|
||||
MaxRetries: 5,
|
||||
RetryInterval: 1800,
|
||||
AutoRenew: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateRenewalPolicy failed: %v", err)
|
||||
}
|
||||
if updated.Name != "Default Renamed" {
|
||||
t.Errorf("expected updated name, got %s", updated.Name)
|
||||
}
|
||||
if updated.RenewalWindowDays != 45 {
|
||||
t.Errorf("expected window 45, got %d", updated.RenewalWindowDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Update_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
_, err := svc.UpdateRenewalPolicy(ctx, "rp-missing", domain.RenewalPolicy{
|
||||
Name: "X", RenewalWindowDays: 30, MaxRetries: 3, RetryInterval: 3600,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing policy, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Delete_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
rp := &domain.RenewalPolicy{
|
||||
ID: "rp-default", Name: "Default", RenewalWindowDays: 30,
|
||||
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{"rp-default": rp}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
if err := svc.DeleteRenewalPolicy(ctx, "rp-default"); err != nil {
|
||||
t.Fatalf("DeleteRenewalPolicy failed: %v", err)
|
||||
}
|
||||
if _, exists := repo.Policies["rp-default"]; exists {
|
||||
t.Error("expected policy to be removed from repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Delete_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &mockRenewalPolicyRepo{Policies: map[string]*domain.RenewalPolicy{}}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
err := svc.DeleteRenewalPolicy(ctx, "rp-missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing policy, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalPolicyService_Delete_InUseConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
rp := &domain.RenewalPolicy{
|
||||
ID: "rp-active", Name: "Active", RenewalWindowDays: 30,
|
||||
MaxRetries: 3, RetryInterval: 3600, AutoRenew: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
repo := &mockRenewalPolicyRepo{
|
||||
Policies: map[string]*domain.RenewalPolicy{"rp-active": rp},
|
||||
DeleteErr: ErrRenewalPolicyInUse,
|
||||
}
|
||||
svc := NewRenewalPolicyService(repo)
|
||||
|
||||
err := svc.DeleteRenewalPolicy(ctx, "rp-active")
|
||||
if err == nil {
|
||||
t.Fatal("expected in-use conflict error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrRenewalPolicyInUse) {
|
||||
t.Errorf("expected ErrRenewalPolicyInUse, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -749,11 +749,21 @@ func (m *mockPolicyRepo) AddRule(rule *domain.PolicyRule) {
|
||||
m.Rules[rule.ID] = rule
|
||||
}
|
||||
|
||||
// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository
|
||||
// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository.
|
||||
//
|
||||
// G-1: repo contract extended with Create/Update/Delete to support the
|
||||
// /api/v1/renewal-policies CRUD endpoints. Per-method *Err fields let tests
|
||||
// force specific repo failures (duplicate name → 23505, FK RESTRICT on Delete
|
||||
// → 23503) without standing up a real Postgres connection. The sentinel
|
||||
// errors `ErrRenewalPolicyDuplicateName` and `ErrRenewalPolicyInUse` are the
|
||||
// typed envelopes the service / handler layers translate into 409 Conflict.
|
||||
type mockRenewalPolicyRepo struct {
|
||||
Policies map[string]*domain.RenewalPolicy
|
||||
GetErr error
|
||||
ListErr error
|
||||
Policies map[string]*domain.RenewalPolicy
|
||||
GetErr error
|
||||
ListErr error
|
||||
CreateErr error
|
||||
UpdateErr error
|
||||
DeleteErr error
|
||||
}
|
||||
|
||||
func (m *mockRenewalPolicyRepo) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
|
||||
@@ -775,9 +785,49 @@ func (m *mockRenewalPolicyRepo) List(ctx context.Context) ([]*domain.RenewalPoli
|
||||
for _, p := range m.Policies {
|
||||
policies = append(policies, p)
|
||||
}
|
||||
// Deterministic ordering mirrors the production repo's ORDER BY name,
|
||||
// so pagination-boundary assertions don't become flaky under map
|
||||
// iteration randomness.
|
||||
sort.Slice(policies, func(i, j int) bool {
|
||||
return policies[i].Name < policies[j].Name
|
||||
})
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (m *mockRenewalPolicyRepo) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
|
||||
if m.CreateErr != nil {
|
||||
return m.CreateErr
|
||||
}
|
||||
if _, exists := m.Policies[policy.ID]; exists {
|
||||
return m.CreateErr
|
||||
}
|
||||
m.Policies[policy.ID] = policy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRenewalPolicyRepo) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
|
||||
if m.UpdateErr != nil {
|
||||
return m.UpdateErr
|
||||
}
|
||||
if _, exists := m.Policies[id]; !exists {
|
||||
return errNotFound
|
||||
}
|
||||
policy.ID = id
|
||||
m.Policies[id] = policy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRenewalPolicyRepo) Delete(ctx context.Context, id string) error {
|
||||
if m.DeleteErr != nil {
|
||||
return m.DeleteErr
|
||||
}
|
||||
if _, exists := m.Policies[id]; !exists {
|
||||
return errNotFound
|
||||
}
|
||||
delete(m.Policies, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRenewalPolicyRepo) AddPolicy(policy *domain.RenewalPolicy) {
|
||||
m.Policies[policy.ID] = policy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user