package test import ( "RenewCMS/internal/domain/author" "RenewCMS/internal/infrastructure/useCases" "context" "errors" "strings" "testing" "unicode/utf8" ) func TestAuthorSlugGeneration(t *testing.T) { cases := []struct{ name, slug string }{ {"Camille Roche", "camille-roche"}, {"Inès Duarte", "ines-duarte"}, {"Zoë Œuvre Łukasz", "zoe-oeuvre-lukasz"}, {"Straße Müller", "strasse-muller"}, {"Dr. O'Neil Smith", "dr-o-neil-smith"}, {" --Marta-- ", "marta"}, {"!!!", "author"}, {"", "author"}, {strings.Repeat("ab ", 60), strings.TrimRight(strings.Repeat("ab-", 60)[:100], "-")}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := author.GenerateSlug(c.name) if got != c.slug || !author.IsValidSlug(got) { t.Errorf("slug = %q, expected %q", got, c.slug) } }) } t.Run("The suffix always fits in 100 characters", func(t *testing.T) { base := strings.Repeat("a", 100) for _, attempt := range []int{2, 9, 10, 99, 100} { got := author.SlugWithSuffix(base, attempt) if utf8.RuneCountInString(got) > 100 || !author.IsValidSlug(got) || !strings.HasSuffix(got, "-"+itoaForTest(attempt)) { t.Errorf("attempt %d: %q", attempt, got) } } if author.SlugWithSuffix("marta", 1) != "marta" { t.Errorf("the first attempt must keep the base slug") } }) } func itoaForTest(value int) string { digits := "" for value > 0 { digits = string(rune('0'+value%10)) + digits value /= 10 } return digits } func TestAuthorValueRules(t *testing.T) { t.Run("Names are counted in runes after trimming", func(t *testing.T) { for name, valid := range map[string]bool{"": false, " ": false, "a": true, strings.Repeat("é", 100): true, strings.Repeat("é", 101): false, " a ": true} { if author.IsValidName(name) != valid { t.Errorf("%q valid = %v", name, !valid) } } }) t.Run("The bio is counted in runes", func(t *testing.T) { if author.ValidateBio(strings.Repeat("é", 280)) != nil || !errors.Is(author.ValidateBio(strings.Repeat("é", 281)), author.ErrBioTooLong) { t.Errorf("bio rule broken") } }) t.Run("URLs must be https with a host", func(t *testing.T) { for raw, valid := range map[string]bool{"https://a.example": true, "http://a.example": false, "a.example": false, "https://": false, "": false, "https://a.example/" + strings.Repeat("a", 2048): false} { if (author.ValidateURL(raw) == nil) != valid { t.Errorf("%q valid = %v", raw, !valid) } } }) t.Run("An empty website means none", func(t *testing.T) { empty := "" if got, err := author.NormalizeWebsite(&empty); got != nil || err != nil { t.Errorf("got %v %v", got, err) } }) t.Run("At most five links", func(t *testing.T) { link := author.SocialLink{Type: "x", URL: "https://x.com/a"} if author.ValidateLinks([]author.SocialLink{link, link, link, link, link}) != nil || !errors.Is(author.ValidateLinks([]author.SocialLink{link, link, link, link, link, link}), author.ErrTooManyLinks) { t.Errorf("link count rule broken") } }) } func TestAuthorUseCases(t *testing.T) { t.Run("The own author falls back to later slugs when the repository reports a race", func(t *testing.T) { users := newMockUserRepo() users.users[1] = userNamed(1, "marta", "Marta Alves") authors := plainAuthors() authors.fail.failNextWith("Create", author.ErrSlugTaken) uc := useCases.NewCreateMyAuthorUseCase(authors, users, discardRecorder) created, err := uc.Create(context.Background(), 1) if err != nil || created.Slug != "marta-alves-2" { t.Errorf("created %+v %v", created, err) } }) t.Run("Deleting an author without articles does not need a destination", func(t *testing.T) { authors := plainAuthors() authors.seed(2, "B", "b") uc := useCases.NewDeleteAuthorUseCase(authors, discardRecorder) err := uc.Delete(context.Background(), 2, useCases.Reassignment{Mode: useCases.ReassignMalformed}) if err != nil || len(authors.deleteCalls) != 1 || authors.deleteCalls[0].reassignTo != nil { t.Errorf("err %v calls %+v", err, authors.deleteCalls) } }) t.Run("A caller without author rights cannot touch another author", func(t *testing.T) { users := newMockUserRepo() authors := plainAuthors() authors.seed(2, "B", "b") uc := useCases.NewUpdateAuthorUseCase(authors, users, newMockImageRepo(), discardRecorder) bio := "x" _, err := uc.Update(context.Background(), 2, useCases.AuthorCaller{UserID: 9, Role: "user"}, author.Patch{Bio: &bio}) if !errors.Is(err, author.ErrAuthorRights) { t.Errorf("err = %v", err) } }) } func TestAuthorDomainBoundaries(t *testing.T) { t.Run("A base slug that exactly fits is kept as is before the suffix", func(t *testing.T) { base := strings.Repeat("a", 97) + "-" got := author.SlugWithSuffix(base, 2) if got != base+"-2" { t.Errorf("got %q", got) } }) t.Run("A url of exactly the maximum length is accepted and one more is refused", func(t *testing.T) { prefix := "https://example.com/" exact := prefix + strings.Repeat("a", author.MaxURLLength-len(prefix)) if err := author.ValidateURL(exact); err != nil { t.Errorf("exact length refused: %v", err) } if err := author.ValidateURL(exact + "a"); !errors.Is(err, author.ErrInvalidURL) { t.Errorf("over length accepted: %v", err) } }) t.Run("A byline always carries a links array", func(t *testing.T) { bare := author.Author{ID: 1, Name: "A", Slug: "a"}.Byline() kept := author.Author{ID: 1, SocialLinks: []author.SocialLink{{Type: "x", URL: "https://x.com/a"}}}.Byline() if bare.SocialLinks == nil || len(bare.SocialLinks) != 0 || len(kept.SocialLinks) != 1 { t.Errorf("bare %+v kept %+v", bare, kept) } }) }