package catalog import ( "context" "os" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestValidateAttributeIDsOwned(t *testing.T) { a := uuid.MustParse("11111111-1111-1111-1111-111111111111") b := uuid.MustParse("22222222-2222-2222-2222-222222222222") c := uuid.MustParse("33333333-3333-3333-3333-333333333333") t.Run("empty request", func(t *testing.T) { if err := validateAttributeIDsOwned(nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("all owned", func(t *testing.T) { if err := validateAttributeIDsOwned([]uuid.UUID{a, b}, []uuid.UUID{b, a}); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("duplicate request ids still ok when owned", func(t *testing.T) { // Ownership query returns distinct rows; duplicates are allowed through to INSERT // (unique constraint rejects them later — same as the old per-row path). if err := validateAttributeIDsOwned([]uuid.UUID{a, a}, []uuid.UUID{a}); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("missing id", func(t *testing.T) { err := validateAttributeIDsOwned([]uuid.UUID{a, c}, []uuid.UUID{a}) if err == nil || err.Error() != "attribute not found" { t.Fatalf("got %v, want attribute not found", err) } }) t.Run("cross-tenant treated as missing", func(t *testing.T) { err := validateAttributeIDsOwned([]uuid.UUID{b}, nil) if err == nil || err.Error() != "attribute not found" { t.Fatalf("got %v, want attribute not found", err) } }) } func asUUID(t *testing.T, v any) uuid.UUID { t.Helper() switch x := v.(type) { case uuid.UUID: return x case string: id, err := uuid.Parse(x) if err != nil { t.Fatalf("parse uuid %q: %v", x, err) } return id case [16]byte: return uuid.UUID(x) default: t.Fatalf("unexpected uuid type %T", v) return uuid.Nil } } func TestReplaceCategoryAttributesBatch(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } defer pg.Close() companyID := uuid.New() _, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "links-test-"+companyID.String()[:8]) if err != nil { t.Fatalf("insert company: %v", err) } t.Cleanup(func() { _, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID) }) svc := &Service{Pool: pg} catUnique := "links-cat-" + companyID.String()[:8] if _, err := svc.CreateCategory(ctx, companyID, "Links Test Cat", catUnique, nil, nil); err != nil { t.Fatalf("CreateCategory: %v", err) } attrA, err := svc.CreateAttribute(ctx, companyID, "color", "Color", "string", nil, nil, nil) if err != nil { t.Fatalf("CreateAttribute A: %v", err) } attrB, err := svc.CreateAttribute(ctx, companyID, "size", "Size", "string", nil, nil, nil) if err != nil { t.Fatalf("CreateAttribute B: %v", err) } idA := asUUID(t, attrA["id"]) idB := asUUID(t, attrB["id"]) otherCompany := uuid.New() _, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, otherCompany, "links-other-"+otherCompany.String()[:8]) if err != nil { t.Fatalf("insert other company: %v", err) } t.Cleanup(func() { _, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, otherCompany) }) foreign, err := svc.CreateAttribute(ctx, otherCompany, "foreign", "Foreign", "string", nil, nil, nil) if err != nil { t.Fatalf("CreateAttribute foreign: %v", err) } foreignID := asUUID(t, foreign["id"]) t.Run("batch replace with required flags", func(t *testing.T) { err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idA, idB}, map[string]bool{ idA.String(): true, }) if err != nil { t.Fatalf("ReplaceCategoryAttributes: %v", err) } items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique) if err != nil { t.Fatalf("ListCategoryAttributes: %v", err) } if len(items) != 2 { t.Fatalf("want 2 links, got %d", len(items)) } byAttr := map[uuid.UUID]bool{} for _, item := range items { aid := asUUID(t, item["attribute_id"]) req, _ := item["required"].(bool) byAttr[aid] = req } if !byAttr[idA] { t.Fatal("attribute A should be required") } if byAttr[idB] { t.Fatal("attribute B should not be required") } }) t.Run("replace clears previous links", func(t *testing.T) { err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idB}, nil) if err != nil { t.Fatalf("ReplaceCategoryAttributes: %v", err) } items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique) if err != nil { t.Fatalf("ListCategoryAttributes: %v", err) } if len(items) != 1 { t.Fatalf("want 1 link, got %d", len(items)) } if asUUID(t, items[0]["attribute_id"]) != idB { t.Fatalf("want attribute B, got %v", items[0]["attribute_id"]) } }) t.Run("empty list clears all", func(t *testing.T) { err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, nil, nil) if err != nil { t.Fatalf("ReplaceCategoryAttributes: %v", err) } items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique) if err != nil { t.Fatalf("ListCategoryAttributes: %v", err) } if len(items) != 0 { t.Fatalf("want 0 links, got %d", len(items)) } }) t.Run("missing attribute", func(t *testing.T) { err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{uuid.New()}, nil) if err == nil || err.Error() != "attribute not found" { t.Fatalf("got %v, want attribute not found", err) } }) t.Run("cross-tenant attribute", func(t *testing.T) { err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{foreignID}, nil) if err == nil || err.Error() != "attribute not found" { t.Fatalf("got %v, want attribute not found", err) } items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique) if err != nil { t.Fatalf("ListCategoryAttributes: %v", err) } if len(items) != 0 { t.Fatalf("failed replace must leave links empty, got %d", len(items)) } }) t.Run("category not found", func(t *testing.T) { err := svc.ReplaceCategoryAttributes(ctx, companyID, "no-such-category", []uuid.UUID{idA}, nil) if err == nil || err.Error() != "category not found" { t.Fatalf("got %v, want category not found", err) } }) }