package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "RenewCMS/internal/infrastructure/useCases" "bytes" "context" "errors" "net/http" "net/http/httptest" "slices" "strconv" "testing" "time" ) func exportedArticleIDs(t *testing.T, f *exportFixture) []float64 { t.Helper() files := openExportArchive(t, f.export(t, exportMartaID)) ids := []float64{} for _, raw := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") { ids = append(ids, raw.(map[string]any)["id"].(float64)) } slices.Sort(ids) return ids } func TestAccountExportDataArticleScope(t *testing.T) { t.Run("a draft by someone else credited to the user is excluded", func(t *testing.T) { f := newExportFixture(t) f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID) f.articles.articles[4] = article.Article{ID: 4, Title: "Ines draft", CreatedBy: exportInesID} f.articles.credit(4, 1) if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) { t.Errorf("article ids %v", ids) } }) t.Run("a published article by someone else credited to the user is included", func(t *testing.T) { f := newExportFixture(t) f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID) f.articles.articles[4] = article.Article{ID: 4, Title: "Ines published", CreatedBy: exportInesID, IsOnline: true} f.articles.credit(4, 1) if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2, 4}) { t.Errorf("article ids %v", ids) } }) t.Run("the user's own draft is included", func(t *testing.T) { f := newExportFixture(t) if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) || f.articles.articles[2].IsOnline { t.Errorf("article ids %v", ids) } }) } func TestAccountExportDataCoAuthorsAreReduced(t *testing.T) { f := newExportFixture(t) f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID) coAuthor := f.authors.seed(2, "Dario Costa", "dario-costa") coAuthor.Bio = "Plasterer from Porto" coAuthor.Website = new(string) coAuthor.AvatarID = idPointer(9) coAuthor.SocialLinks = []author.SocialLink{{URL: "https://example.com/dario"}} f.authors.authors[2] = coAuthor f.articles.credit(1, 1, 2) files := openExportArchive(t, f.export(t, exportMartaID)) var keys []string collectKeys(exportJSON(t, files, "authored-content.json"), &keys) for _, key := range keys { if slices.Contains([]string{"bio", "website", "social_links", "avatar_id"}, key) { t.Errorf("authored-content.json exposes %q", key) } } first := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")[0].(map[string]any) credits := first["authors"].([]any) if len(credits) != 2 || credits[1].(map[string]any)["name"] != "Dario Costa" || credits[1].(map[string]any)["slug"] != "dario-costa" { t.Errorf("credits %v", credits) } profile := exportJSON(t, files, "profile.json")["author"].(map[string]any) if _, ok := profile["social_links"]; !ok { t.Errorf("profile.json author must stay complete: %v", profile) } } func TestEventConcernsOtherPerson(t *testing.T) { ownAuthor := idPointer(5) targeted := func(kind string, id uint32) audit.Event { event := audit.NewEvent("x", audit.CategoryUsers) event.Target = audit.Target{Type: kind, ID: strconv.Itoa(int(id))} return event } cases := []struct { name string event audit.Event own *uint32 want bool }{ {"own author", targeted(audit.TargetAuthor, 5), ownAuthor, false}, {"another author", targeted(audit.TargetAuthor, 6), ownAuthor, true}, {"an author when the user has none", targeted(audit.TargetAuthor, 5), nil, true}, {"an author target without an id", audit.Event{Target: audit.Target{Type: audit.TargetAuthor, Name: "Dario"}}, ownAuthor, true}, {"the user themself", targeted(audit.TargetUser, 7), ownAuthor, false}, {"another user", targeted(audit.TargetUser, 8), ownAuthor, true}, {"an invite", targeted(audit.TargetInvite, 3), ownAuthor, true}, {"an article", targeted(audit.TargetArticle, 3), ownAuthor, false}, {"no target", audit.Event{}, nil, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tc.event.ConcernsOtherPerson(7, tc.own); got != tc.want { t.Errorf("got %v, want %v", got, tc.want) } }) } } func TestAccountExportDataDropsEventsOnOtherAuthors(t *testing.T) { f := newExportFixture(t) f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID) own := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors) own.ID, own.Actor = 200, audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID)} own.Target = audit.NewTarget(audit.TargetAuthor, 1, "Marta Alves") other := own other.ID = 201 other.Target = audit.NewTarget(audit.TargetAuthor, 2, "Dario Costa") f.events.events = append(f.events.events, own, other) files := openExportArchive(t, f.export(t, exportMartaID)) ids := []float64{} for _, raw := range exportList(t, exportJSON(t, files, "account-activity.json"), "events") { ids = append(ids, raw.(map[string]any)["id"].(float64)) } if !slices.Contains(ids, 200) || slices.Contains(ids, 201) { t.Errorf("event ids %v", ids) } } func TestAccountExportDataEncodesStraightIntoTheArchive(t *testing.T) { f := newExportFixture(t) files := openExportArchive(t, f.export(t, exportMartaID)) for name, content := range files { if !bytes.HasSuffix(content, []byte("}\n")) || bytes.HasSuffix(content, []byte("\n\n")) || !bytes.HasPrefix(content, []byte("{\n \"")) { t.Errorf("%s is not indented JSON ending with a single newline", name) } } } func TestAccountExportDataConcurrencyCap(t *testing.T) { newBlocked := func(t *testing.T) (*exportFixture, chan struct{}, chan struct{}) { f := newExportFixtureWithCap(t, 1) entered := make(chan struct{}, 1) release := make(chan struct{}) once := false f.images.onListByUploader = func() { if once { return } once = true entered <- struct{}{} <-release } return f, entered, release } t.Run("a build waits for a free slot and gives up when its request ends", func(t *testing.T) { f, entered, release := newBlocked(t) firstDone := make(chan *httptest.ResponseRecorder) go func() { firstDone <- f.export(t, exportMartaID) }() <-entered ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() req := httptest.NewRequestWithContext(ctx, http.MethodGet, exportRoute, nil) req.AddCookie(displayNameCookie(t, f.token, exportInesID, exportTokenVer)) waiting := httptest.NewRecorder() f.router.ServeHTTP(waiting, req) if waiting.Code != http.StatusServiceUnavailable { t.Errorf("waiting build = %d, want 503", waiting.Code) } close(release) if first := <-firstDone; first.Code != http.StatusOK { t.Errorf("first build = %d", first.Code) } if len(f.recorder.events) != 1 { t.Errorf("events %d, want only the first build's", len(f.recorder.events)) } if retried := f.export(t, exportInesID); retried.Code != http.StatusOK { t.Errorf("refused user must keep the slot free, got %d", retried.Code) } }) t.Run("the slot is released when a build fails", func(t *testing.T) { f := newExportFixtureWithCap(t, 1) f.images.fail.failNext("ListByUploader") failed := f.export(t, exportMartaID) next := f.export(t, exportMartaID) if failed.Code != http.StatusInternalServerError || next.Code != http.StatusOK { t.Errorf("failed %d then %d", failed.Code, next.Code) } }) t.Run("the use case returns the context error", func(t *testing.T) { f, entered, release := newBlocked(t) usecase := useCases.NewExportUserDataUseCase(f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, 1) f.images.onListByUploader = func() { entered <- struct{}{}; <-release } done := make(chan error) go func() { _, err := usecase.Export(context.Background(), exportMartaID); done <- err }() <-entered ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := usecase.Export(ctx, exportInesID) if !errors.Is(err, context.Canceled) { t.Errorf("err %v", err) } close(release) if err := <-done; err != nil { t.Errorf("first build %v", err) } }) }