mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
fix(repo,service): SCALE-002 — push pagination into SQL for target/issuer/team/agent_group
Sprint 2 unified-master-audit closure. Pre-fix four service List
endpoints (target, issuer, team, agent_group) called repoFoo.List(ctx)
to fetch the full table then sliced in memory:
rows, _ := s.repo.List(ctx)
total := int64(len(rows))
start := (page - 1) * perPage
end := start + perPage
return rows[start:end], total, nil
This page-sliced in memory pattern marshals every row per request —
fine on small fleets but unacceptable for multi-tenant or large-fleet
deploys. The agent_group case was worse — the service explicitly
ignored page/perPage and returned the entire slice.
Fix:
- New ListPaginated(ctx, limit, offset) method on each of the four
repositories. Postgres implementations push LIMIT + OFFSET into
the SQL plus a SELECT COUNT(*) for the total. Mirrors the cursor
pattern already in internal/repository/postgres/certificate.go.
- Each ListPaginated normalises limit≤0→50 and offset<0→0,
matching the service-layer defaults that already existed.
- Repository interfaces grow the new method so adapters stay
swappable.
- Service List methods now call repoFoo.ListPaginated(ctx, perPage,
(page-1)*perPage) directly — no more memory-slice.
- AgentGroupService.ListAgentGroups closes the Bundle E / Audit
L-020 'page/perPage unused' gap.
Test changes:
- sliceWindow generic helper in testutil_test.go mirrors the SQL
LIMIT/OFFSET semantics for in-memory mocks.
- Six mock implementers (lifecycle_test, testutil_test x2,
agent_group_test, team_test) gain ListPaginated methods.
- TestTeamService_List_SCALE002_PaginationPropagatesToRepo pins
the page=2, perPage=3 → 3 rows of 10 invariant.
Closes SCALE-002.
This commit is contained in:
@@ -961,6 +961,25 @@ func (m *mockTargetRepository) List(ctx context.Context) ([]*domain.DeploymentTa
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
||||
func (m *mockTargetRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) {
|
||||
all, _ := m.List(ctx)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(all) {
|
||||
return nil, int64(len(all)), nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
return all[offset:], int64(len(all)), nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[offset:end], int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (m *mockTargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
||||
target, ok := m.targets[id]
|
||||
if !ok {
|
||||
@@ -1233,6 +1252,25 @@ func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, erro
|
||||
return issuers, nil
|
||||
}
|
||||
|
||||
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
||||
func (m *mockIssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) {
|
||||
all, _ := m.List(ctx)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(all) {
|
||||
return nil, int64(len(all)), nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
return all[offset:], int64(len(all)), nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[offset:end], int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
||||
issuer, ok := m.issuers[id]
|
||||
if !ok {
|
||||
|
||||
@@ -210,6 +210,11 @@ type OCSPResponderRepository interface {
|
||||
type IssuerRepository interface {
|
||||
// List returns all issuers, optionally filtered.
|
||||
List(ctx context.Context) ([]*domain.Issuer, error)
|
||||
// ListPaginated returns a window of issuers (sorted by created_at DESC)
|
||||
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16) —
|
||||
// pushes pagination into the SQL layer so admin pages don't marshal
|
||||
// the full table per request.
|
||||
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error)
|
||||
// Get retrieves an issuer by ID.
|
||||
Get(ctx context.Context, id string) (*domain.Issuer, error)
|
||||
// Create stores a new issuer.
|
||||
@@ -227,6 +232,10 @@ type IssuerRepository interface {
|
||||
type TargetRepository interface {
|
||||
// List returns all targets, optionally filtered.
|
||||
List(ctx context.Context) ([]*domain.DeploymentTarget, error)
|
||||
// ListPaginated returns a window of deployment targets (sorted by
|
||||
// created_at DESC) plus the total row count. SCALE-002 closure
|
||||
// (Sprint 2, 2026-05-16).
|
||||
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error)
|
||||
// Get retrieves a target by ID.
|
||||
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
|
||||
// Create stores a new target.
|
||||
@@ -550,6 +559,9 @@ type NotificationRepository interface {
|
||||
type TeamRepository interface {
|
||||
// List returns all teams.
|
||||
List(ctx context.Context) ([]*domain.Team, error)
|
||||
// ListPaginated returns a window of teams (sorted by created_at DESC)
|
||||
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16).
|
||||
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error)
|
||||
// Get retrieves a team by ID.
|
||||
Get(ctx context.Context, id string) (*domain.Team, error)
|
||||
// Create stores a new team.
|
||||
@@ -578,6 +590,9 @@ type CertificateProfileRepository interface {
|
||||
type AgentGroupRepository interface {
|
||||
// List returns all agent groups.
|
||||
List(ctx context.Context) ([]*domain.AgentGroup, error)
|
||||
// ListPaginated returns a window of agent groups (sorted by name)
|
||||
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16).
|
||||
ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error)
|
||||
// Get retrieves an agent group by ID.
|
||||
Get(ctx context.Context, id string) (*domain.AgentGroup, error)
|
||||
// Create stores a new agent group.
|
||||
|
||||
@@ -44,6 +44,40 @@ func (r *AgentGroupRepository) List(ctx context.Context) ([]*domain.AgentGroup,
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// ListPaginated returns a slice of agent groups bounded by limit/offset
|
||||
// plus the total count. SCALE-002 closure (Sprint 2, 2026-05-16).
|
||||
func (r *AgentGroupRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agent_groups`).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count agent groups: %w", err)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at
|
||||
FROM agent_groups ORDER BY name LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query agent groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var groups []*domain.AgentGroup
|
||||
for rows.Next() {
|
||||
g, err := scanAgentGroup(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return groups, total, nil
|
||||
}
|
||||
|
||||
// Get retrieves an agent group by ID.
|
||||
func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.AgentGroup, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
|
||||
@@ -57,6 +57,48 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
||||
return issuers, nil
|
||||
}
|
||||
|
||||
// ListPaginated returns a slice of issuers bounded by limit/offset plus the
|
||||
// total count. SCALE-002 closure (Sprint 2, 2026-05-16).
|
||||
func (r *IssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issuers`).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count issuers: %w", err)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled,
|
||||
last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'),
|
||||
created_at, updated_at
|
||||
FROM issuers
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query issuers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var issuers []*domain.Issuer
|
||||
for rows.Next() {
|
||||
var iss domain.Issuer
|
||||
if err := rows.Scan(&iss.ID, &iss.Name, &iss.Type, &iss.Config,
|
||||
&iss.EncryptedConfig, &iss.Enabled,
|
||||
&iss.LastTestedAt, &iss.TestStatus, &iss.Source,
|
||||
&iss.CreatedAt, &iss.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan issuer: %w", err)
|
||||
}
|
||||
issuers = append(issuers, &iss)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("error iterating issuer rows: %w", err)
|
||||
}
|
||||
return issuers, total, nil
|
||||
}
|
||||
|
||||
// Get retrieves an issuer by ID
|
||||
func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
||||
var issuer domain.Issuer
|
||||
|
||||
@@ -82,6 +82,48 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// ListPaginated returns a slice of deployment targets bounded by limit/offset
|
||||
// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16) — pushes
|
||||
// pagination into SQL so the admin UI doesn't marshal the entire targets
|
||||
// table per request. limit≤0 is normalised to 50; offset<0 to 0.
|
||||
func (r *TargetRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deployment_targets`).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count targets: %w", err)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT `+targetSelectColumns+`
|
||||
FROM deployment_targets
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query targets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var targets []*domain.DeploymentTarget
|
||||
for rows.Next() {
|
||||
var t domain.DeploymentTarget
|
||||
if err := scanTarget(rows, &t); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan target: %w", err)
|
||||
}
|
||||
targets = append(targets, &t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("error iterating target rows: %w", err)
|
||||
}
|
||||
return targets, total, nil
|
||||
}
|
||||
|
||||
// Get retrieves a target by ID
|
||||
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
||||
var target domain.DeploymentTarget
|
||||
|
||||
@@ -53,6 +53,44 @@ func (r *TeamRepository) List(ctx context.Context) ([]*domain.Team, error) {
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// ListPaginated returns a slice of teams bounded by limit/offset plus the
|
||||
// total count. SCALE-002 closure (Sprint 2, 2026-05-16).
|
||||
func (r *TeamRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM teams`).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count teams: %w", err)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, description, created_at, updated_at
|
||||
FROM teams
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query teams: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var teams []*domain.Team
|
||||
for rows.Next() {
|
||||
var team domain.Team
|
||||
if err := rows.Scan(&team.ID, &team.Name, &team.Description,
|
||||
&team.CreatedAt, &team.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan team: %w", err)
|
||||
}
|
||||
teams = append(teams, &team)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("error iterating team rows: %w", err)
|
||||
}
|
||||
return teams, total, nil
|
||||
}
|
||||
|
||||
// Get retrieves a team by ID
|
||||
func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, error) {
|
||||
var team domain.Team
|
||||
|
||||
@@ -31,27 +31,28 @@ func NewAgentGroupService(
|
||||
}
|
||||
|
||||
// ListAgentGroups returns paginated agent groups (handler interface method).
|
||||
//
|
||||
// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is now pushed
|
||||
// into the SQL layer via AgentGroupRepository.ListPaginated, closing
|
||||
// the Bundle E / Audit L-020 "page/perPage unused" gap.
|
||||
func (s *AgentGroupService) ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||
// List() does not yet take pagination params. Marked explicitly so
|
||||
// ineffassign sees no dead store and future maintainers see the
|
||||
// vestigial params rather than a misleading default-applied clamp.
|
||||
_ = page
|
||||
_ = perPage
|
||||
|
||||
groups, err := s.groupRepo.List(ctx)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
groups, total, err := s.groupRepo.ListPaginated(ctx, perPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list agent groups: %w", err)
|
||||
}
|
||||
total := int64(len(groups))
|
||||
|
||||
var result []domain.AgentGroup
|
||||
for _, g := range groups {
|
||||
if g != nil {
|
||||
result = append(result, *g)
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ func (m *mockAgentGroupRepo) List(ctx context.Context) ([]*domain.AgentGroup, er
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
||||
func (m *mockAgentGroupRepo) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error) {
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return sliceWindow(all, limit, offset), int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (m *mockAgentGroupRepo) Get(ctx context.Context, id string) (*domain.AgentGroup, error) {
|
||||
if m.GetErr != nil {
|
||||
return nil, m.GetErr
|
||||
|
||||
@@ -58,6 +58,9 @@ func (s *IssuerService) GetRegistry() *IssuerRegistry {
|
||||
}
|
||||
|
||||
// List returns a paginated list of issuers.
|
||||
//
|
||||
// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is pushed into
|
||||
// the SQL layer via IssuerRepository.ListPaginated.
|
||||
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -65,21 +68,8 @@ func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
issuers, err := s.issuerRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list issuers: %w", err)
|
||||
}
|
||||
total := int64(len(issuers))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return issuers[start:end], total, nil
|
||||
offset := (page - 1) * perPage
|
||||
return s.issuerRepo.ListPaginated(ctx, perPage, offset)
|
||||
}
|
||||
|
||||
// Get retrieves an issuer by ID.
|
||||
|
||||
@@ -89,6 +89,11 @@ func NewTargetService(
|
||||
}
|
||||
|
||||
// List returns a paginated list of deployment targets.
|
||||
//
|
||||
// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is pushed into
|
||||
// the SQL layer via TargetRepository.ListPaginated. Pre-fix this called
|
||||
// targetRepo.List(ctx) and sliced in memory, which marshalled the
|
||||
// entire targets table per request — a problem on large-fleet deploys.
|
||||
func (s *TargetService) List(ctx context.Context, page, perPage int) ([]*domain.DeploymentTarget, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -96,21 +101,8 @@ func (s *TargetService) List(ctx context.Context, page, perPage int) ([]*domain.
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
targets, err := s.targetRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list targets: %w", err)
|
||||
}
|
||||
total := int64(len(targets))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return targets[start:end], total, nil
|
||||
offset := (page - 1) * perPage
|
||||
return s.targetRepo.ListPaginated(ctx, perPage, offset)
|
||||
}
|
||||
|
||||
// Get retrieves a deployment target by ID.
|
||||
|
||||
@@ -31,6 +31,9 @@ func NewTeamService(
|
||||
}
|
||||
|
||||
// List returns a paginated list of teams.
|
||||
//
|
||||
// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is pushed into
|
||||
// the SQL layer via TeamRepository.ListPaginated.
|
||||
func (s *TeamService) List(ctx context.Context, page, perPage int) ([]*domain.Team, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -38,21 +41,8 @@ func (s *TeamService) List(ctx context.Context, page, perPage int) ([]*domain.Te
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
teams, err := s.teamRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list teams: %w", err)
|
||||
}
|
||||
total := int64(len(teams))
|
||||
start := (page - 1) * perPage
|
||||
if start >= int(total) {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + perPage
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
return teams[start:end], total, nil
|
||||
offset := (page - 1) * perPage
|
||||
return s.teamRepo.ListPaginated(ctx, perPage, offset)
|
||||
}
|
||||
|
||||
// Get retrieves a team by ID.
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -30,6 +31,15 @@ func (m *mockTeamRepo) List(ctx context.Context) ([]*domain.Team, error) {
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
||||
func (m *mockTeamRepo) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error) {
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return sliceWindow(all, limit, offset), int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (m *mockTeamRepo) Get(ctx context.Context, id string) (*domain.Team, error) {
|
||||
if m.GetErr != nil {
|
||||
return nil, m.GetErr
|
||||
@@ -688,3 +698,42 @@ func TestTeamService_NilAuditService(t *testing.T) {
|
||||
t.Errorf("expected ID to be generated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeamService_List_SCALE002_PaginationPropagatesToRepo pins the
|
||||
// SCALE-002 closure (Sprint 2, 2026-05-16): the service no longer
|
||||
// fetches the full table and slices in memory; it propagates limit +
|
||||
// offset to the repository layer. The mock's ListPaginated uses
|
||||
// sliceWindow which mirrors the SQL LIMIT/OFFSET semantics, so a
|
||||
// request for page 2, perPage 3 against a 10-row table must return
|
||||
// rows 3..5 of the underlying slice — proof the offset is being
|
||||
// computed and threaded correctly.
|
||||
//
|
||||
// Map iteration order in Go is non-deterministic, so this test uses
|
||||
// a sortable team name and walks the result to assert "the second
|
||||
// window of three" without depending on insertion order. The IDs are
|
||||
// not asserted because the mock's underlying map shuffles them; what
|
||||
// IS asserted is total + len + that the window came from the same
|
||||
// 10-row population.
|
||||
func TestTeamService_List_SCALE002_PaginationPropagatesToRepo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockTeamRepo := newMockTeamRepository()
|
||||
mockAuditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(mockAuditRepo)
|
||||
teamService := NewTeamService(mockTeamRepo, auditService)
|
||||
for i := 0; i < 10; i++ {
|
||||
mockTeamRepo.AddTeam(&domain.Team{
|
||||
ID: "team-scale002-" + strconv.Itoa(i),
|
||||
Name: "Team " + strconv.Itoa(i),
|
||||
})
|
||||
}
|
||||
teams, total, err := teamService.List(ctx, 2, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if total != 10 {
|
||||
t.Errorf("total = %d; want 10", total)
|
||||
}
|
||||
if len(teams) != 3 {
|
||||
t.Errorf("len(teams) = %d; want 3 (page 2 of 10 with perPage 3 should yield 3 rows)", len(teams))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,27 @@ import (
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
// sliceWindow is a tiny helper for the SCALE-002 mock ListPaginated
|
||||
// implementations. It mirrors the SQL LIMIT/OFFSET window over an
|
||||
// in-memory slice with the same normalisation as the postgres repos
|
||||
// (limit≤0 → return as-is; offset<0 → 0; out-of-range → empty).
|
||||
func sliceWindow[T any](all []T, limit, offset int) []T {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(all) {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
return all[offset:]
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[offset:end]
|
||||
}
|
||||
|
||||
// testEncryptionKey is a deterministic passphrase for unit tests that
|
||||
// exercise IssuerService/TargetService write paths. After the C-2 remediation
|
||||
// these services fail closed when no key is configured, so happy-path tests
|
||||
@@ -1246,6 +1267,15 @@ func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget,
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
||||
func (m *mockTargetRepo) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) {
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return sliceWindow(all, limit, offset), int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -1535,6 +1565,15 @@ func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, erro
|
||||
return issuers, nil
|
||||
}
|
||||
|
||||
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
||||
func (m *mockIssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) {
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return sliceWindow(all, limit, offset), int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
||||
if m.GetErr != nil {
|
||||
return nil, m.GetErr
|
||||
|
||||
Reference in New Issue
Block a user