gito/services/core/internal/provider/provider_test.go
toki 8e6d531718 feat(provider): ProviderAdapter 인터페이스를 abstraction하여 다중 provider 지원으로 확장한다
- ProviderAdapter 인터페이스를 정의하고 AWS/GitLab provider 구현체를 추가한다
- CRD model에 ProviderRef 및 status 필드를 추가하여 provider 관리를 표준화한다
- ControlPlane에 provider registry를 통합하여 runtime/periodic event routing을 provider별로 분기한다
- Router에 routeByProvider 메서드를 추가하여 provider별 event 라우팅을 지원한다
- Event 시스템에 outbound event 전송 기능을 추가한다
- 관련 테스트를 추가하여 provider별 동작을 검증한다
2026-06-20 14:52:39 +09:00

537 lines
17 KiB
Go

package provider
import (
"context"
"reflect"
"testing"
"time"
"git.toki-labs.com/toki/gito/services/core/internal/core"
)
func TestConfigNormalizedTrimsIdentityFields(t *testing.T) {
config := Config{
ID: ProviderID(" GitHub "),
Endpoint: " https://api.github.com ",
CredentialRef: " secret://gito/providers/github ",
Capabilities: CapabilityChangeRequest | CapabilityWebhook,
}
got := config.Normalized()
if got.ID != ProviderGitHub {
t.Fatalf("ID: got %q want %q", got.ID, ProviderGitHub)
}
if got.Endpoint != "https://api.github.com" {
t.Fatalf("Endpoint: got %q", got.Endpoint)
}
if got.CredentialRef != "secret://gito/providers/github" {
t.Fatalf("CredentialRef: got %q", got.CredentialRef)
}
}
func TestCapabilityFlags(t *testing.T) {
capabilities := CapabilityChangeRequest | CapabilityWebhook | CapabilityComment
if !capabilities.Has(CapabilityChangeRequest) {
t.Fatal("expected change request capability")
}
if !capabilities.Has(CapabilityWebhook) {
t.Fatal("expected webhook capability")
}
if !capabilities.Has(CapabilityComment) {
t.Fatal("expected comment capability")
}
if capabilities.Has(CapabilityChecks) {
t.Fatal("did not expect checks capability")
}
if capabilities.Has(0) {
t.Fatal("zero capability should not be supported")
}
}
func TestConfigSupportsCapability(t *testing.T) {
config := Config{
ID: ProviderForgejo,
Capabilities: CapabilityWebhook | CapabilityChecks,
}
if !config.Supports(CapabilityWebhook) {
t.Fatal("expected webhook support")
}
if !config.Supports(CapabilityChecks) {
t.Fatal("expected checks support")
}
if config.Supports(CapabilityChangeRequest) {
t.Fatal("did not expect change request support")
}
}
func TestChangeRequestMappingCandidatesCoverProviderDifferences(t *testing.T) {
mappings := ChangeRequestMappingCandidates()
if len(mappings) != 3 {
t.Fatalf("mapping count: got %d want 3", len(mappings))
}
seen := make(map[ProviderID]ChangeRequestMappingCandidate, len(mappings))
for _, mapping := range mappings {
seen[mapping.Provider] = mapping
assertChangeRequestMappingTargets(t, mapping)
assertChangeRequestMappingStates(t, mapping)
assertChangeRequestMappingActions(t, mapping)
if len(mapping.ExtensionFields) == 0 {
t.Fatalf("%s mapping should define provider extension fields", mapping.Provider)
}
}
for _, provider := range []ProviderID{ProviderGitHub, ProviderGitLab, ProviderGitea} {
if _, ok := seen[provider]; !ok {
t.Fatalf("missing %s change request mapping", provider)
}
}
if seen[ProviderGitHub].ProviderObject != "pull_request" {
t.Fatalf("github provider object: %q", seen[ProviderGitHub].ProviderObject)
}
if seen[ProviderGitLab].ProviderObject != "merge_request" {
t.Fatalf("gitlab provider object: %q", seen[ProviderGitLab].ProviderObject)
}
if seen[ProviderGitea].ProviderObject != "pull_request" {
t.Fatalf("gitea provider object: %q", seen[ProviderGitea].ProviderObject)
}
}
func TestChangeRequestProviderExtensionsStayOutOfCoreDTO(t *testing.T) {
crType := reflect.TypeOf(core.ChangeRequest{})
for _, field := range []string{"Extensions", "ExtensionFields", "ProviderFields", "Raw"} {
if _, ok := crType.FieldByName(field); ok {
t.Fatalf("provider-specific field %q must not live on core.ChangeRequest", field)
}
}
}
func assertChangeRequestMappingTargets(t *testing.T, mapping ChangeRequestMappingCandidate) {
t.Helper()
targets := make(map[string]bool, len(mapping.Fields))
for _, field := range mapping.Fields {
targets[field.Target] = true
if field.Source == "" {
t.Fatalf("%s field %q has empty source", mapping.Provider, field.Target)
}
}
for _, target := range []string{
"provider",
"external_id",
"repo_id",
"source_branch",
"target_branch",
"title",
"body",
"draft",
"state",
} {
if !targets[target] {
t.Fatalf("%s mapping missing target field %q", mapping.Provider, target)
}
}
}
func assertChangeRequestMappingStates(t *testing.T, mapping ChangeRequestMappingCandidate) {
t.Helper()
states := make(map[core.ChangeRequestState]bool, len(mapping.States))
for _, state := range mapping.States {
states[state.State] = true
if state.Source == "" {
t.Fatalf("%s state %q has empty source", mapping.Provider, state.State)
}
}
for _, state := range []core.ChangeRequestState{
core.ChangeRequestOpen,
core.ChangeRequestMerged,
core.ChangeRequestClosed,
} {
if !states[state] {
t.Fatalf("%s mapping missing state %q", mapping.Provider, state)
}
}
}
func assertChangeRequestMappingActions(t *testing.T, mapping ChangeRequestMappingCandidate) {
t.Helper()
actions := make(map[core.ChangeRequestAction]bool, len(mapping.Actions))
for _, action := range mapping.Actions {
actions[action] = true
}
for _, action := range []core.ChangeRequestAction{
core.ChangeRequestActionCreate,
core.ChangeRequestActionUpdate,
core.ChangeRequestActionComment,
core.ChangeRequestActionRequestReview,
core.ChangeRequestActionMerge,
core.ChangeRequestActionClose,
} {
if !actions[action] {
t.Fatalf("%s mapping missing action %q", mapping.Provider, action)
}
}
}
type fakeChangeRequestAdapter struct {
created core.ChangeRequestCreate
updatedID string
updated core.ChangeRequestUpdate
commentedID string
commented core.ChangeRequestComment
reviewedID string
reviewed core.ChangeRequestReviewRequest
mergedID string
merged core.ChangeRequestMerge
closedID string
closed core.ChangeRequestClose
changeRequest core.ChangeRequest
}
func (f *fakeChangeRequestAdapter) CreateChangeRequest(ctx context.Context, input core.ChangeRequestCreate) (core.ChangeRequest, error) {
f.created = input
return f.changeRequest, nil
}
func (f *fakeChangeRequestAdapter) UpdateChangeRequest(ctx context.Context, id string, input core.ChangeRequestUpdate) (core.ChangeRequest, error) {
f.updatedID = id
f.updated = input
return f.changeRequest, nil
}
func (f *fakeChangeRequestAdapter) CommentChangeRequest(ctx context.Context, id string, input core.ChangeRequestComment) error {
f.commentedID = id
f.commented = input
return nil
}
func (f *fakeChangeRequestAdapter) RequestChangeRequestReview(ctx context.Context, id string, input core.ChangeRequestReviewRequest) error {
f.reviewedID = id
f.reviewed = input
return nil
}
func (f *fakeChangeRequestAdapter) MergeChangeRequest(ctx context.Context, id string, input core.ChangeRequestMerge) (core.ChangeRequest, error) {
f.mergedID = id
f.merged = input
return f.changeRequest, nil
}
func (f *fakeChangeRequestAdapter) CloseChangeRequest(ctx context.Context, id string, input core.ChangeRequestClose) (core.ChangeRequest, error) {
f.closedID = id
f.closed = input
return f.changeRequest, nil
}
var _ ChangeRequestAdapter = (*fakeChangeRequestAdapter)(nil)
func TestChangeRequestAdapterContractActions(t *testing.T) {
title := "Updated title"
draft := true
fake := &fakeChangeRequestAdapter{
changeRequest: core.ChangeRequest{
Provider: string(ProviderGitHub),
ExternalID: "42",
RepoID: "repo-1",
SourceBranch: "feature/a",
TargetBranch: "main",
Title: "Add CR model",
Body: "body",
Draft: false,
State: core.ChangeRequestOpen,
},
}
created, err := fake.CreateChangeRequest(context.Background(), core.ChangeRequestCreate{
RepoID: "repo-1",
SourceBranch: "feature/a",
TargetBranch: "main",
Title: "Add CR model",
Draft: false,
})
if err != nil {
t.Fatalf("CreateChangeRequest: %v", err)
}
if created.ExternalID != "42" || fake.created.Title != "Add CR model" {
t.Fatalf("create action: created=%+v input=%+v", created, fake.created)
}
updated, err := fake.UpdateChangeRequest(context.Background(), "42", core.ChangeRequestUpdate{
Title: &title,
Draft: &draft,
})
if err != nil {
t.Fatalf("UpdateChangeRequest: %v", err)
}
if updated.ExternalID != "42" || fake.updatedID != "42" || fake.updated.Title == nil || *fake.updated.Title != title {
t.Fatalf("update action: updated=%+v id=%q input=%+v", updated, fake.updatedID, fake.updated)
}
if fake.updated.Draft == nil || *fake.updated.Draft != draft {
t.Fatalf("update draft: %+v", fake.updated)
}
if err := fake.CommentChangeRequest(context.Background(), "42", core.ChangeRequestComment{Body: "looks good"}); err != nil {
t.Fatalf("CommentChangeRequest: %v", err)
}
if fake.commentedID != "42" || fake.commented.Body != "looks good" {
t.Fatalf("comment action: id=%q input=%+v", fake.commentedID, fake.commented)
}
if err := fake.RequestChangeRequestReview(context.Background(), "42", core.ChangeRequestReviewRequest{Reviewers: []string{"alice"}}); err != nil {
t.Fatalf("RequestChangeRequestReview: %v", err)
}
if fake.reviewedID != "42" || len(fake.reviewed.Reviewers) != 1 || fake.reviewed.Reviewers[0] != "alice" {
t.Fatalf("request review action: id=%q input=%+v", fake.reviewedID, fake.reviewed)
}
merged, err := fake.MergeChangeRequest(context.Background(), "42", core.ChangeRequestMerge{
CommitTitle: "Merge CR",
CommitMessage: "Merge provider-neutral CR",
})
if err != nil {
t.Fatalf("MergeChangeRequest: %v", err)
}
if merged.ExternalID != "42" || fake.mergedID != "42" || fake.merged.CommitTitle != "Merge CR" {
t.Fatalf("merge action: merged=%+v id=%q input=%+v", merged, fake.mergedID, fake.merged)
}
closed, err := fake.CloseChangeRequest(context.Background(), "42", core.ChangeRequestClose{Reason: "superseded"})
if err != nil {
t.Fatalf("CloseChangeRequest: %v", err)
}
if closed.ExternalID != "42" || fake.closedID != "42" || fake.closed.Reason != "superseded" {
t.Fatalf("close action: closed=%+v id=%q input=%+v", closed, fake.closedID, fake.closed)
}
}
// --- Fake adapter for testing ---
// fakeWebhookAdapter is a test implementation of WebhookAdapter.
type fakeWebhookAdapter struct {
verified bool
verifyErr error
candidates []WebhookCandidate
normalizeErr error
// Recorded requests for verifier and normalizer.
verifyReq WebhookRequest
normalizeReq WebhookRequest
}
func (f *fakeWebhookAdapter) VerifyWebhook(ctx context.Context, req WebhookRequest) error {
if f.verifyErr != nil {
return f.verifyErr
}
f.verified = true
f.verifyReq = req
return nil
}
func (f *fakeWebhookAdapter) NormalizeWebhook(ctx context.Context, req WebhookRequest) ([]WebhookCandidate, error) {
if f.normalizeErr != nil {
return nil, f.normalizeErr
}
f.normalizeReq = req
return f.candidates, nil
}
// verifyWebhookAdapter ensures WebhookAdapter compile-time assertion.
var _ WebhookAdapter = (*fakeWebhookAdapter)(nil)
func TestWebhookAdapterContractCompileTime(t *testing.T) {
// fakeWebhookAdapter is already assigned to var _ WebhookAdapter.
// This test passes if the file compiles.
}
func TestWebhookAdapterContractRequestDelivery(t *testing.T) {
revision := &core.RevisionEvent{
RepoID: "test-repo",
Branch: "main",
Before: "abc123",
After: "def456",
ObservedAt: time.Now().UTC(),
}
now := time.Now().UTC()
req := WebhookRequest{
Provider: ProviderForgejo,
EventType: "push",
ExternalID: "delivery-123",
Payload: []byte(`{"ref":"refs/heads/main"}`),
Headers: map[string][]string{
"X-Forgejo-Signature": {"sha256=abc"},
},
Query: map[string][]string{
"repo_id": {"test-repo"},
},
ReceivedAt: now,
}
fake := &fakeWebhookAdapter{
candidates: []WebhookCandidate{
{
Type: "branch.updated",
Provider: ProviderForgejo,
ExternalID: "delivery-123",
Revision: revision,
},
},
}
ctx := context.Background()
// Verify delivery
if err := fake.VerifyWebhook(ctx, req); err != nil {
t.Fatalf("VerifyWebhook: %v", err)
}
if !fake.verified {
t.Fatal("expected verified to be true")
}
// Normalize delivery
candidates, err := fake.NormalizeWebhook(ctx, req)
if err != nil {
t.Fatalf("NormalizeWebhook: %v", err)
}
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
c := candidates[0]
if c.Type != "branch.updated" {
t.Fatalf("Type: got %q want %q", c.Type, "branch.updated")
}
if c.Provider != ProviderForgejo {
t.Fatalf("Provider: got %q want %q", c.Provider, ProviderForgejo)
}
if c.ExternalID != "delivery-123" {
t.Fatalf("ExternalID: got %q want %q", c.ExternalID, "delivery-123")
}
if c.Revision == nil {
t.Fatal("expected Revision to be set")
}
if c.Revision.RepoID != "test-repo" {
t.Fatalf("Revision.RepoID: got %q want %q", c.Revision.RepoID, "test-repo")
}
if c.Revision.Branch != "main" {
t.Fatalf("Revision.Branch: got %q want %q", c.Revision.Branch, "main")
}
// Verify that the request was actually delivered to the verifier.
if fake.verifyReq.Provider == "" {
t.Fatal("expected verifyReq.Provider to be set")
}
if fake.verifyReq.Provider != ProviderForgejo {
t.Fatalf("verifyReq.Provider: got %q want %q", fake.verifyReq.Provider, ProviderForgejo)
}
if fake.verifyReq.EventType != "push" {
t.Fatalf("verifyReq.EventType: got %q want %q", fake.verifyReq.EventType, "push")
}
if fake.verifyReq.ExternalID != "delivery-123" {
t.Fatalf("verifyReq.ExternalID: got %q want %q", fake.verifyReq.ExternalID, "delivery-123")
}
if string(fake.verifyReq.Payload) != `{"ref":"refs/heads/main"}` {
t.Fatalf("verifyReq.Payload: got %q", fake.verifyReq.Payload)
}
if len(fake.verifyReq.Headers) != 1 {
t.Fatalf("verifyReq.Headers: got %d entries, want 1", len(fake.verifyReq.Headers))
}
if sigs, ok := fake.verifyReq.Headers["X-Forgejo-Signature"]; !ok {
t.Fatal("verifyReq.Headers['X-Forgejo-Signature'] not found")
} else if len(sigs) != 1 || sigs[0] != "sha256=abc" {
t.Fatalf("verifyReq.Headers['X-Forgejo-Signature']: got %v, want [sha256=abc]", sigs)
}
if len(fake.verifyReq.Query) != 1 {
t.Fatalf("verifyReq.Query: got %d entries, want 1", len(fake.verifyReq.Query))
}
if repos, ok := fake.verifyReq.Query["repo_id"]; !ok {
t.Fatal("verifyReq.Query['repo_id'] not found")
} else if len(repos) != 1 || repos[0] != "test-repo" {
t.Fatalf("verifyReq.Query['repo_id']: got %v, want [test-repo]", repos)
}
if !fake.verifyReq.ReceivedAt.Equal(now) {
t.Fatalf("verifyReq.ReceivedAt: got %v, want %v", fake.verifyReq.ReceivedAt, now)
}
// Verify that the request was actually delivered to the normalizer.
if fake.normalizeReq.Provider != ProviderForgejo {
t.Fatalf("normalizeReq.Provider: got %q want %q", fake.normalizeReq.Provider, ProviderForgejo)
}
if fake.normalizeReq.EventType != "push" {
t.Fatalf("normalizeReq.EventType: got %q want %q", fake.normalizeReq.EventType, "push")
}
if fake.normalizeReq.ExternalID != "delivery-123" {
t.Fatalf("normalizeReq.ExternalID: got %q want %q", fake.normalizeReq.ExternalID, "delivery-123")
}
if string(fake.normalizeReq.Payload) != `{"ref":"refs/heads/main"}` {
t.Fatalf("normalizeReq.Payload: got %q", fake.normalizeReq.Payload)
}
if len(fake.normalizeReq.Headers) != 1 {
t.Fatalf("normalizeReq.Headers: got %d entries, want 1", len(fake.normalizeReq.Headers))
}
if sigs, ok := fake.normalizeReq.Headers["X-Forgejo-Signature"]; !ok {
t.Fatal("normalizeReq.Headers['X-Forgejo-Signature'] not found")
} else if len(sigs) != 1 || sigs[0] != "sha256=abc" {
t.Fatalf("normalizeReq.Headers['X-Forgejo-Signature']: got %v, want [sha256=abc]", sigs)
}
if len(fake.normalizeReq.Query) != 1 {
t.Fatalf("normalizeReq.Query: got %d entries, want 1", len(fake.normalizeReq.Query))
}
if repos, ok := fake.normalizeReq.Query["repo_id"]; !ok {
t.Fatal("normalizeReq.Query['repo_id'] not found")
} else if len(repos) != 1 || repos[0] != "test-repo" {
t.Fatalf("normalizeReq.Query['repo_id']: got %v, want [test-repo]", repos)
}
if !fake.normalizeReq.ReceivedAt.Equal(now) {
t.Fatalf("normalizeReq.ReceivedAt: got %v, want %v", fake.normalizeReq.ReceivedAt, now)
}
}
func TestWebhookRequestEmptyPayloadPreservation(t *testing.T) {
req := WebhookRequest{
Provider: ProviderGitHub,
EventType: "pull_request",
ExternalID: "",
Payload: []byte{},
Headers: nil,
Query: nil,
ReceivedAt: time.Time{},
}
if len(req.Payload) != 0 {
t.Fatalf("expected empty payload, got %d bytes", len(req.Payload))
}
if req.ExternalID != "" {
t.Fatalf("expected empty ExternalID, got %q", req.ExternalID)
}
if !req.ReceivedAt.IsZero() {
t.Fatalf("expected zero ReceivedAt")
}
}
func TestWebhookCandidateNoSecretRequirement(t *testing.T) {
// WebhookCandidate must not expose any secret-related fields.
// This is verified by the struct definition itself:
// - Type string
// - Provider ProviderID
// - ExternalID string
// - Revision *core.RevisionEvent
// No secret, signature, or token fields.
c := WebhookCandidate{
Type: "branch.updated",
Provider: ProviderForgejo,
ExternalID: "delivery-1",
}
// Verify the struct has no secret-related fields by construction.
// If any secret field were added, compilation would still pass,
// but the test ensures the current minimal metadata contract.
if c.Type == "" {
t.Fatal("Type should be settable")
}
if c.Provider == "" {
t.Fatal("Provider should be settable")
}
}