Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions internal/domain/scope_seed_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package domain_test

import (
"context"
"errors"
"testing"

"github.com/mrchatam/Trace/internal/domain"
)

func TestImportSeedScopeBadKind(t *testing.T) {
svc, st := openDomain(t)
ctx := context.Background()

badKinds := []string{"Feature", "", "domain", "layerz", "businessx", "FEATURE"}
for _, k := range badKinds {
_, _, err := svc.ImportSeedScope(ctx, domain.SeedScope{
ID: "11111111-1111-1111-1111-111111111111",
Slug: "bad-kind-" + k,
Title: "Bad Kind",
Kind: k,
})
if err == nil {
t.Fatalf("expected error for kind %q", k)
}
var ve *domain.ErrValidation
if !errors.As(err, &ve) {
t.Fatalf("kind %q: expected *ErrValidation, got %T: %v", k, err, err)
}
if ve.Msg == "" || !contains(ve.Msg, "feature, layer, or business") {
t.Fatalf("kind %q: bad message: %q", k, ve.Msg)
}
}

scopes, err := st.ListScopes()
if err != nil {
t.Fatal(err)
}
if len(scopes) != 0 {
t.Fatalf("no scope row should be persisted for bad kind, got %d", len(scopes))
}
}

func TestImportSeedScopeSlugCollisionDifferentID(t *testing.T) {
svc, st := openDomain(t)
ctx := context.Background()

existing, err := svc.CreateScope(ctx, domain.ScopeInput{
Slug: "collision",
Title: "Existing",
Kind: "feature",
})
if err != nil {
t.Fatal(err)
}

_, _, err = svc.ImportSeedScope(ctx, domain.SeedScope{
ID: "22222222-2222-2222-2222-222222222222",
Slug: "collision",
Title: "Different",
Kind: "layer",
})
if err == nil {
t.Fatal("expected error for slug collision with different id")
}
var ve *domain.ErrValidation
if !errors.As(err, &ve) {
t.Fatalf("expected *ErrValidation, got %T: %v", err, err)
}
if ve.Msg == "" || !contains(ve.Msg, "already exists with id") || !contains(ve.Msg, existing.ID) || !contains(ve.Msg, "22222222-2222-2222-2222-222222222222") {
t.Fatalf("bad message: %q", ve.Msg)
}

got, err := st.GetScopeBySlug("collision")
if err != nil {
t.Fatal(err)
}
if got.ID != existing.ID || got.Title != "Existing" || got.Kind != "feature" {
t.Fatalf("original scope mutated: %+v", got)
}
}

func TestImportSeedScopeReimportSameIDSlug(t *testing.T) {
svc, st := openDomain(t)
ctx := context.Background()

seed := domain.SeedScope{
ID: "33333333-3333-3333-3333-333333333333",
Slug: "reimport",
Title: "Reimport Test",
Kind: "business",
}

sc1, inserted1, err := svc.ImportSeedScope(ctx, seed)
if err != nil {
t.Fatalf("first import: %v", err)
}
if !inserted1 {
t.Fatal("first import should be inserted")
}
if sc1.ID != seed.ID || sc1.Slug != seed.Slug || sc1.Title != seed.Title || sc1.Kind != seed.Kind {
t.Fatalf("first import mismatch: %+v", sc1)
}

sc2, inserted2, err := svc.ImportSeedScope(ctx, seed)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if inserted2 {
t.Fatal("re-import should not be inserted")
}
if sc2.ID != sc1.ID || sc2.Slug != sc1.Slug || sc2.Title != sc1.Title || sc2.Kind != sc1.Kind {
t.Fatalf("re-import mismatch: %+v", sc2)
}

evs, err := st.ListEventsByEntity(domain.EntityScope, sc1.ID)
if err != nil {
t.Fatal(err)
}
createdCount := 0
for _, e := range evs {
if e.Type == domain.EventEntityCreated {
createdCount++
}
}
if createdCount != 1 {
t.Fatalf("expected exactly one entity.created event, got %d", createdCount)
}
}

func TestImportSeedScopeMissingSlugTitle(t *testing.T) {
svc, _ := openDomain(t)
ctx := context.Background()

for _, tc := range []struct {
name string
sc domain.SeedScope
}{
{"empty slug", domain.SeedScope{ID: "1", Slug: "", Title: "T", Kind: "feature"}},
{"empty title", domain.SeedScope{ID: "1", Slug: "s", Title: "", Kind: "feature"}},
{"both empty", domain.SeedScope{ID: "1", Slug: "", Title: "", Kind: "feature"}},
} {
_, _, err := svc.ImportSeedScope(ctx, tc.sc)
if err == nil {
t.Fatalf("%s: expected error", tc.name)
}
var ve *domain.ErrValidation
if !errors.As(err, &ve) {
t.Fatalf("%s: expected *ErrValidation, got %T: %v", tc.name, err, err)
}
if ve.Msg == "" || !contains(ve.Msg, "slug and title required") {
t.Fatalf("%s: bad message: %q", tc.name, ve.Msg)
}
}
}

func TestImportSeedScopeEmptyIDExistingSlug(t *testing.T) {
svc, st := openDomain(t)
ctx := context.Background()

existing, err := svc.CreateScope(ctx, domain.ScopeInput{
Slug: "existing-slug",
Title: "Existing Scope",
Kind: "feature",
})
if err != nil {
t.Fatal(err)
}

sc, inserted, err := svc.ImportSeedScope(ctx, domain.SeedScope{
ID: "",
Slug: "existing-slug",
Title: "Different Title",
Kind: "layer",
})
if err != nil {
t.Fatalf("import with empty id and existing slug: %v", err)
}
if inserted {
t.Fatal("import with empty id and existing slug should not be inserted")
}
if sc.ID != existing.ID {
t.Fatalf("expected reused id %s, got %s", existing.ID, sc.ID)
}
if sc.Slug != "existing-slug" || sc.Title != "Different Title" || sc.Kind != "layer" {
t.Fatalf("scope fields not updated: %+v", sc)
}

scopes, err := st.ListScopes()
if err != nil {
t.Fatal(err)
}
if len(scopes) != 1 {
t.Fatalf("expected exactly 1 scope, got %d", len(scopes))
}

evs, err := st.ListEventsByEntity(domain.EntityScope, existing.ID)
if err != nil {
t.Fatal(err)
}
createdCount := 0
for _, e := range evs {
if e.Type == domain.EventEntityCreated {
createdCount++
}
}
if createdCount != 1 {
t.Fatalf("expected exactly one entity.created event, got %d", createdCount)
}
}

func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && findSubstring(s, substr))
}

func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
33 changes: 25 additions & 8 deletions internal/domain/seed_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"

"github.com/google/uuid"
Expand All @@ -12,15 +13,15 @@ import (

// SeedImportSummary is the import result for CLI JSON output.
type SeedImportSummary struct {
OK bool `json:"ok"`
Created map[string][]string `json:"created"`
Links int `json:"links"`
Findings int `json:"findings"`
Alternatives int `json:"alternatives"`
Transitions int `json:"transitions"`
OK bool `json:"ok"`
Created map[string][]string `json:"created"`
Links int `json:"links"`
Findings int `json:"findings"`
Alternatives int `json:"alternatives"`
Transitions int `json:"transitions"`
PromotionCandidates []PromotionCandidate `json:"promotion_candidates"`
PromotionCandidatesTruncated bool `json:"promotion_candidates_truncated,omitempty"`
PromotionHint string `json:"promotion_hint,omitempty"`
PromotionHint string `json:"promotion_hint,omitempty"`
}

// ImportSeedDocument idempotently imports seed JSON v1 (DF-81/83/84).
Expand Down Expand Up @@ -277,7 +278,6 @@ func (s *Service) importSeedDocumentBody(ctx context.Context, doc SeedDocument,
return nil
}


func seedEntityExists(s *store.Store, entityType, id string) (bool, error) {
if id == "" {
return false, nil
Expand Down Expand Up @@ -710,10 +710,27 @@ func (s *Service) ImportSeedScope(ctx context.Context, in SeedScope) (store.Scop
if slug == "" || title == "" {
return store.Scope{}, false, &ErrValidation{Msg: "scope slug and title required"}
}
switch kind {
case store.ScopeKindFeature, store.ScopeKindLayer, store.ScopeKindBusiness:
default:
return store.Scope{}, false, &ErrValidation{Msg: "scope kind must be feature, layer, or business: " + slug}
}

id := strings.TrimSpace(in.ID)
if existing, err := s.store.GetScopeBySlug(slug); err == nil {
if id == "" {
id = existing.ID
} else if existing.ID != id {
return store.Scope{}, false, &ErrValidation{Msg: fmt.Sprintf("scope slug %q already exists with id %s while seed has id %s", slug, existing.ID, id)}
}
} else if !isNotFoundErr(err) {
return store.Scope{}, false, err
}

if id == "" {
id = uuid.NewString()
}

existed, err := scopeExists(s.store, id)
if err != nil {
return store.Scope{}, false, err
Expand Down
103 changes: 103 additions & 0 deletions internal/httpapi/seed_scope_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package httpapi_test

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/mrchatam/Trace/internal/domain"
"github.com/mrchatam/Trace/internal/store"
)

func TestSeedImportBadKindReturns400(t *testing.T) {
dir, srv := openFixture(t)
h := srv.Handler()

seed := domain.SeedDocument{
Scopes: []domain.SeedScope{
{ID: "11111111-1111-1111-1111-111111111111", Slug: "bad-kind", Title: "Bad", Kind: "Feature"},
},
}
inputPath := filepath.Join(dir, "seed_bad_kind.json")
raw, _ := json.MarshalIndent(seed, "", " ")
if err := os.WriteFile(inputPath, raw, 0o644); err != nil {
t.Fatal(err)
}

body, _ := json.Marshal(map[string]string{"input_path": inputPath})
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v1/seed/import", bytes.NewReader(body)))
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d %s", rr.Code, rr.Body.String())
}
var env map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env["error"].(map[string]any)["code"] != "VALIDATION_ERROR" {
t.Fatalf("code: %v", env)
}
}

func TestSeedImportSlugCollisionReturns400(t *testing.T) {
dir, srv := openFixture(t)
h := srv.Handler()

st, err := store.Open(dir)
if err != nil {
t.Fatal(err)
}
svc := domain.New(st)
existing, err := svc.CreateScope(context.Background(), domain.ScopeInput{
Slug: "collision",
Title: "Existing",
Kind: "feature",
})
if err != nil {
t.Fatal(err)
}
st.Close()

seed := domain.SeedDocument{
Scopes: []domain.SeedScope{
{ID: "22222222-2222-2222-2222-222222222222", Slug: "collision", Title: "Different", Kind: "layer"},
},
}
inputPath := filepath.Join(dir, "seed_collision.json")
raw, _ := json.MarshalIndent(seed, "", " ")
if err := os.WriteFile(inputPath, raw, 0o644); err != nil {
t.Fatal(err)
}

body, _ := json.Marshal(map[string]string{"input_path": inputPath})
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v1/seed/import", bytes.NewReader(body)))
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d %s", rr.Code, rr.Body.String())
}
var env map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env["error"].(map[string]any)["code"] != "VALIDATION_ERROR" {
t.Fatalf("code: %v", env)
}
msg, _ := env["error"].(map[string]any)["message"].(string)
if msg == "" || !contains(msg, "already exists with id") || !contains(msg, existing.ID) || !contains(msg, "22222222-2222-2222-2222-222222222222") {
t.Fatalf("bad message: %q", msg)
}
}

func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
Loading