package service import ( "context" "errors" "strconv" "strings" "testing" "github.com/certctl-io/certctl/internal/domain" ) // mockTeamRepo is a test implementation of TeamRepository type mockTeamRepo struct { teams map[string]*domain.Team CreateErr error UpdateErr error DeleteErr error GetErr error ListErr error } func (m *mockTeamRepo) List(ctx context.Context) ([]*domain.Team, error) { if m.ListErr != nil { return nil, m.ListErr } var teams []*domain.Team for _, t := range m.teams { teams = append(teams, t) } 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 } team, ok := m.teams[id] if !ok { return nil, errNotFound } return team, nil } func (m *mockTeamRepo) Create(ctx context.Context, team *domain.Team) error { if m.CreateErr != nil { return m.CreateErr } m.teams[team.ID] = team return nil } func (m *mockTeamRepo) Update(ctx context.Context, team *domain.Team) error { if m.UpdateErr != nil { return m.UpdateErr } m.teams[team.ID] = team return nil } func (m *mockTeamRepo) Delete(ctx context.Context, id string) error { if m.DeleteErr != nil { return m.DeleteErr } delete(m.teams, id) return nil } func (m *mockTeamRepo) AddTeam(team *domain.Team) { m.teams[team.ID] = team } func newMockTeamRepository() *mockTeamRepo { return &mockTeamRepo{ teams: make(map[string]*domain.Team), } } // TestTeamService_List tests retrieving teams with pagination func TestTeamService_List(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Add test teams for i := 0; i < 5; i++ { mockTeamRepo.AddTeam(&domain.Team{ ID: "team-" + string(rune(i)), Name: "Team " + string(rune(48+i)), }) } teams, total, err := teamService.List(ctx, 1, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } if total != 5 { t.Errorf("expected total 5, got %d", total) } if len(teams) != 2 { t.Errorf("expected 2 teams on page 1, got %d", len(teams)) } } // TestTeamService_List_DefaultPagination tests default pagination values func TestTeamService_List_DefaultPagination(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Add test teams for i := 0; i < 10; i++ { mockTeamRepo.AddTeam(&domain.Team{ ID: "team-" + string(rune(i)), Name: "Team " + string(rune(48+i)), }) } // Test page < 1 defaults to 1 teams, total, err := teamService.List(ctx, 0, 5) if err != nil { t.Fatalf("unexpected error: %v", err) } if total != 10 { t.Errorf("expected total 10, got %d", total) } if len(teams) != 5 { t.Errorf("expected 5 teams, got %d", len(teams)) } // Test perPage < 1 defaults to 50 teams, _, err = teamService.List(ctx, 1, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(teams) != 10 { t.Errorf("expected 10 teams with perPage=50, got %d", len(teams)) } } // TestTeamService_List_RepositoryError tests error handling from repo func TestTeamService_List_RepositoryError(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) mockTeamRepo.ListErr = errors.New("database error") _, _, err := teamService.List(ctx, 1, 50) if err == nil { t.Fatalf("expected error, got nil") } if !strings.Contains(err.Error(), "database error") { t.Errorf("expected error containing 'database error', got %v", err) } } // TestTeamService_List_EmptyResult tests empty list response func TestTeamService_List_EmptyResult(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) teams, total, err := teamService.List(ctx, 1, 50) if err != nil { t.Fatalf("unexpected error: %v", err) } if total != 0 { t.Errorf("expected total 0, got %d", total) } if len(teams) != 0 { t.Errorf("expected empty slice, got %d teams", len(teams)) } } // TestTeamService_List_PageBeyondRange tests pagination beyond available data func TestTeamService_List_PageBeyondRange(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Add only 3 teams for i := 0; i < 3; i++ { mockTeamRepo.AddTeam(&domain.Team{ ID: "team-" + string(rune(i)), Name: "Team " + string(rune(48+i)), }) } // Request page beyond range teams, total, err := teamService.List(ctx, 10, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } if total != 3 { t.Errorf("expected total 3, got %d", total) } if teams != nil && len(teams) != 0 { t.Errorf("expected empty slice for page beyond range, got %d teams", len(teams)) } } // TestTeamService_Get tests retrieving a single team func TestTeamService_Get(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) testTeam := &domain.Team{ ID: "team-1", Name: "Test Team", } mockTeamRepo.AddTeam(testTeam) team, err := teamService.Get(ctx, "team-1") if err != nil { t.Fatalf("unexpected error: %v", err) } if team.ID != "team-1" || team.Name != "Test Team" { t.Errorf("expected team-1/Test Team, got %s/%s", team.ID, team.Name) } } // TestTeamService_Get_NotFound tests retrieval of nonexistent team func TestTeamService_Get_NotFound(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) _, err := teamService.Get(ctx, "nonexistent") if err == nil { t.Fatalf("expected error for nonexistent team, got nil") } } // TestTeamService_Create tests creating a new team func TestTeamService_Create(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) team := &domain.Team{ Name: "New Team", Description: "A test team", } err := teamService.Create(ctx, team, "test-user") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify ID was generated if team.ID == "" { t.Errorf("expected ID to be generated, got empty") } if !(team.ID[:5] == "team-") { t.Logf("note: generated ID is %s", team.ID) } // Verify timestamps were set if team.CreatedAt.IsZero() { t.Errorf("expected CreatedAt to be set") } if team.UpdatedAt.IsZero() { t.Errorf("expected UpdatedAt to be set") } // Verify team was stored stored, err := teamService.Get(ctx, team.ID) if err != nil { t.Fatalf("failed to retrieve created team: %v", err) } if stored.Name != "New Team" { t.Errorf("expected name 'New Team', got %s", stored.Name) } } // TestTeamService_Create_EmptyName tests validation on empty name func TestTeamService_Create_EmptyName(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) team := &domain.Team{ Name: "", } err := teamService.Create(ctx, team, "test-user") if err == nil { t.Fatalf("expected validation error for empty name, got nil") } if !strings.Contains(err.Error(), "team name is required") { t.Errorf("expected error containing 'team name is required', got: %v", err) } } // TestTeamService_Create_WithExistingID tests preserving provided ID func TestTeamService_Create_WithExistingID(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) team := &domain.Team{ ID: "custom-team-id", Name: "Custom Team", } err := teamService.Create(ctx, team, "test-user") if err != nil { t.Fatalf("unexpected error: %v", err) } if team.ID != "custom-team-id" { t.Errorf("expected ID to be preserved as custom-team-id, got %s", team.ID) } } // TestTeamService_Create_RepositoryError tests repo error handling func TestTeamService_Create_RepositoryError(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) mockTeamRepo.CreateErr = errors.New("database insert failed") team := &domain.Team{ Name: "Test Team", } err := teamService.Create(ctx, team, "test-user") if err == nil { t.Fatalf("expected error, got nil") } } // TestTeamService_Create_AuditRecorded tests audit event recording func TestTeamService_Create_AuditRecorded(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) team := &domain.Team{ ID: "audit-test-team", Name: "Audit Test Team", } err := teamService.Create(ctx, team, "audit-user") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify audit event was recorded if len(mockAuditRepo.Events) != 1 { t.Errorf("expected 1 audit event, got %d", len(mockAuditRepo.Events)) } if mockAuditRepo.Events[0].Action != "create_team" { t.Errorf("expected action 'create_team', got %s", mockAuditRepo.Events[0].Action) } if mockAuditRepo.Events[0].ResourceID != "audit-test-team" { t.Errorf("expected resource ID 'audit-test-team', got %s", mockAuditRepo.Events[0].ResourceID) } } // TestTeamService_Update tests updating an existing team func TestTeamService_Update(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Create initial team initialTeam := &domain.Team{ ID: "team-update", Name: "Original Name", Description: "Original description", } mockTeamRepo.AddTeam(initialTeam) // Update team updateTeam := &domain.Team{ Name: "Updated Name", Description: "Updated description", } err := teamService.Update(ctx, "team-update", updateTeam, "update-user") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify ID was set correctly if updateTeam.ID != "team-update" { t.Errorf("expected ID to be set to team-update, got %s", updateTeam.ID) } // Verify team was updated updated, err := teamService.Get(ctx, "team-update") if err != nil { t.Fatalf("failed to retrieve updated team: %v", err) } if updated.Name != "Updated Name" { t.Errorf("expected name 'Updated Name', got %s", updated.Name) } } // TestTeamService_Update_EmptyName tests validation on update func TestTeamService_Update_EmptyName(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) mockTeamRepo.AddTeam(&domain.Team{ ID: "team-1", Name: "Original", }) updateTeam := &domain.Team{ Name: "", } err := teamService.Update(ctx, "team-1", updateTeam, "user") if err == nil { t.Fatalf("expected validation error for empty name, got nil") } } // TestTeamService_Update_RepositoryError tests repo error handling func TestTeamService_Update_RepositoryError(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) mockTeamRepo.UpdateErr = errors.New("database update failed") updateTeam := &domain.Team{ Name: "Updated", } err := teamService.Update(ctx, "team-1", updateTeam, "user") if err == nil { t.Fatalf("expected error, got nil") } } // TestTeamService_Delete tests deleting a team func TestTeamService_Delete(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Create team to delete mockTeamRepo.AddTeam(&domain.Team{ ID: "team-delete", Name: "Team to Delete", }) err := teamService.Delete(ctx, "team-delete", "delete-user") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify team was deleted _, err = teamService.Get(ctx, "team-delete") if err == nil { t.Errorf("expected error for deleted team, got nil") } } // TestTeamService_Delete_RepositoryError tests repo error handling func TestTeamService_Delete_RepositoryError(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) mockTeamRepo.DeleteErr = errors.New("database delete failed") err := teamService.Delete(ctx, "team-1", "user") if err == nil { t.Fatalf("expected error, got nil") } } // TestTeamService_ListTeams_HandlerInterface tests handler interface method func TestTeamService_ListTeams_HandlerInterface(t *testing.T) { mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Add test teams for i := 0; i < 3; i++ { mockTeamRepo.AddTeam(&domain.Team{ ID: "team-" + string(rune(i)), Name: "Team " + string(rune(48+i)), }) } teams, total, err := teamService.ListTeams(context.Background(), 1, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } if total != 3 { t.Errorf("expected total 3, got %d", total) } if len(teams) != 3 { t.Errorf("expected 3 teams (ListTeams doesn't paginate), got %d", len(teams)) } } // TestTeamService_GetTeam_HandlerInterface tests handler interface method func TestTeamService_GetTeam_HandlerInterface(t *testing.T) { mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) testTeam := &domain.Team{ ID: "handler-team", Name: "Handler Test Team", } mockTeamRepo.AddTeam(testTeam) team, err := teamService.GetTeam(context.Background(), "handler-team") if err != nil { t.Fatalf("unexpected error: %v", err) } if team.ID != "handler-team" || team.Name != "Handler Test Team" { t.Errorf("expected handler-team/Handler Test Team, got %s/%s", team.ID, team.Name) } } // TestTeamService_CreateTeam_HandlerInterface tests handler interface method func TestTeamService_CreateTeam_HandlerInterface(t *testing.T) { mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) team := domain.Team{ Name: "Handler Create Team", Description: "Created via handler", } result, err := teamService.CreateTeam(context.Background(), team) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.ID == "" { t.Errorf("expected ID to be generated") } if result.Name != "Handler Create Team" { t.Errorf("expected name 'Handler Create Team', got %s", result.Name) } if result.CreatedAt.IsZero() { t.Errorf("expected CreatedAt to be set") } } // TestTeamService_UpdateTeam_HandlerInterface tests handler interface method func TestTeamService_UpdateTeam_HandlerInterface(t *testing.T) { mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Create initial team mockTeamRepo.AddTeam(&domain.Team{ ID: "handler-update-team", Name: "Original", }) updateTeam := domain.Team{ Name: "Updated via Handler", Description: "Handler update", } result, err := teamService.UpdateTeam(context.Background(), "handler-update-team", updateTeam) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.ID != "handler-update-team" { t.Errorf("expected ID handler-update-team, got %s", result.ID) } if result.Name != "Updated via Handler" { t.Errorf("expected name 'Updated via Handler', got %s", result.Name) } } // TestTeamService_DeleteTeam_HandlerInterface tests handler interface method func TestTeamService_DeleteTeam_HandlerInterface(t *testing.T) { mockTeamRepo := newMockTeamRepository() mockAuditRepo := newMockAuditRepository() auditService := NewAuditService(mockAuditRepo) teamService := NewTeamService(mockTeamRepo, auditService) // Create team to delete mockTeamRepo.AddTeam(&domain.Team{ ID: "handler-delete-team", Name: "To Delete", }) err := teamService.DeleteTeam(context.Background(), "handler-delete-team") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify deletion _, err = mockTeamRepo.Get(context.Background(), "handler-delete-team") if err == nil { t.Errorf("expected error for deleted team") } } // TestTeamService_NilAuditService tests behavior when audit service is nil func TestTeamService_NilAuditService(t *testing.T) { ctx := context.Background() mockTeamRepo := newMockTeamRepository() teamService := NewTeamService(mockTeamRepo, nil) team := &domain.Team{ Name: "Test Team", } // Should not panic with nil audit service err := teamService.Create(ctx, team, "user") if err != nil { t.Fatalf("unexpected error: %v", err) } if team.ID == "" { 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)) } }