diff --git a/bluenote_database_test.go b/bluenote_database_test.go new file mode 100644 index 0000000..5d33733 --- /dev/null +++ b/bluenote_database_test.go @@ -0,0 +1,147 @@ +package writefreely + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" + + "github.com/writefreely/writefreely/config" +) + +// The existing helper uses a disposable SQLite database and skips without +// -tags sqlite. No production database or remote federation server is used. +func TestBlueNoteEmptyActivityPages(t *testing.T) { + app, router := newTemplateTestApp(t, func(cfg *config.Config) { cfg.App.SingleUser = false }) + _, coll, _ := createTemplateTestUser(t, app, "blue0a6m5c") + for _, followerCount := range []int{0, 1} { + if followerCount > 0 { + res, err := app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", + "https://remote.example/users/test", "https://remote.example/inbox", "") + if err != nil { + t.Fatal(err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatal(err) + } + if _, err := app.db.Exec("INSERT INTO remotefollows (collection_id, remote_user_id) VALUES (?, ?)", coll.ID, id); err != nil { + t.Fatal(err) + } + } + for _, kind := range []string{"followers", "following"} { + for _, page := range []int{1, 2} { + t.Run(fmt.Sprintf("%s/followers=%d/page=%d", kind, followerCount, page), func(t *testing.T) { + path := fmt.Sprintf("/api/collections/%s/%s?page=%d", coll.Alias, kind, page) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Accept", "application/activity+json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if string(body["type"]) != `"OrderedCollectionPage"` { + t.Fatalf("unexpected page: %s", rec.Body.String()) + } + // web-core marks orderedItems omitempty, so an empty slice + // is omitted from the current wire format. + var items []json.RawMessage + if raw, exists := body["orderedItems"]; exists { + if err := json.Unmarshal(raw, &items); err != nil { + t.Fatal(err) + } + } + if len(items) != 0 { + t.Errorf("expected empty page, got %s", body["orderedItems"]) + } + wantTotal := "0" + if kind == "followers" { + wantTotal = fmt.Sprint(followerCount) + } + if string(body["totalItems"]) != wantTotal { + t.Errorf("totalItems = %s, want %s", body["totalItems"], wantTotal) + } + if next, exists := body["next"]; exists { + t.Errorf("next must be omitted, got %s", next) + } + }) + } + } + } +} + +func TestBlueNoteClaimDatetimeSlug(t *testing.T) { + app, _ := newTemplateTestApp(t, func(cfg *config.Config) { cfg.App.SingleUser = false }) + for _, alias := range []string{"blue0a6m5c", "otherblog"} { + user, coll, direct := createTemplateTestUser(t, app, alias) + // Current scope: direct creation still uses the title-based slug. + if direct.Slug.String != "hello-world" { + t.Fatalf("direct post slug = %q", direct.Slug.String) + } + for _, viaClaim := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/claim=%t", alias, viaClaim), func(t *testing.T) { + title, content, created := "Draft Title", "Draft body", "2001-02-03T04:05:06Z" + draft, err := app.db.CreatePost(user.ID, 0, &SubmittedPost{Title: &title, Content: &content, Created: &created}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := app.db.Exec("DELETE FROM posts WHERE id = ?", draft.ID); err != nil { + t.Error(err) + } + }) + requests := []ClaimPostRequest{{AnonymousAuthPost: &AnonymousAuthPost{ID: draft.ID}}} + target := alias + if viaClaim { + target = "" + requests[0].CollectionAlias = alias + } + before := time.Now().Truncate(time.Second) + results, err := app.db.ClaimPosts(app.cfg, user.ID, target, &requests) + after := time.Now() + if err != nil { + t.Fatal(err) + } + if results == nil || len(*results) != 1 { + t.Fatalf("unexpected claim results: %#v", results) + } + result := (*results)[0] + if result.Code != http.StatusOK || result.Post == nil { + t.Fatalf("claim failed: %+v", result) + } + slug := result.Post.Slug.String + if alias == "blue0a6m5c" { + if !regexp.MustCompile(`^\d{14}$`).MatchString(slug) { + t.Fatalf("expected datetime slug, got %q", slug) + } + // The implementation uses processing time in time.Local, not + // the saved publication date. Accept a second boundary crossing. + stamp, err := time.ParseInLocation("20060102150405", slug, time.Local) + if err != nil { + t.Fatal(err) + } + if stamp.Before(before) || stamp.After(after) { + t.Errorf("slug time %s outside [%s, %s]", stamp, before, after) + } + } else if slug != "draft-title" { + t.Errorf("other blog slug = %q, want draft-title", slug) + } + var savedSlug string + var savedCollection int64 + if err := app.db.QueryRow("SELECT slug, collection_id FROM posts WHERE id = ?", draft.ID).Scan(&savedSlug, &savedCollection); err != nil { + t.Fatal(err) + } + if savedSlug != slug || savedCollection != coll.ID { + t.Errorf("saved slug/collection = %q/%d, want %q/%d", savedSlug, savedCollection, slug, coll.ID) + } + }) + } + } +} diff --git a/bluenote_posts_test.go b/bluenote_posts_test.go new file mode 100644 index 0000000..3339064 --- /dev/null +++ b/bluenote_posts_test.go @@ -0,0 +1,93 @@ +package writefreely + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/writefreely/writefreely/config" +) + +func TestBlueNoteISO8601UTC(t *testing.T) { + for _, tc := range []struct { + name, input, want string + }{ + {"UTC", "2026-09-05T12:34:56Z", "2026-09-05T12:34:56Z"}, + {"positive offset crosses year", "2026-01-01T00:30:45+09:00", "2025-12-31T15:30:45Z"}, + {"negative offset crosses day", "2026-09-05T23:30:45-07:00", "2026-09-06T06:30:45Z"}, + {"fractional seconds", "2026-09-05T12:34:56.123456+05:30", "2026-09-05T07:04:56Z"}, + } { + t.Run(tc.name, func(t *testing.T) { + created, err := time.Parse(time.RFC3339Nano, tc.input) + if err != nil { + t.Fatal(err) + } + post := &Post{Created: created} + raw := &RawPost{Created: created, Updated: created} + for name, serialize := range map[string]func() string{ + "Post.Created8601": post.Created8601, + "RawPost.Created8601": raw.Created8601, + "RawPost.Updated8601": raw.Updated8601, + } { + t.Run(name, func(t *testing.T) { + if got := serialize(); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } + if post.Created != created || raw.Created != created || raw.Updated != created { + t.Fatal("serialization changed the stored timestamp") + } + }) + } + if got := (&RawPost{}).Updated8601(); got != "" { + t.Fatalf("unset Updated8601 = %q, want empty", got) + } +} + +func TestBlueNoteActivityArticleSummary(t *testing.T) { + cfg := config.New() + cfg.App.Host = "https://example.com" + cfg.App.NotesOnly = false + app := &App{cfg: cfg} + for _, tc := range []struct { + name, content, want string + }{ + {"markdown", "**Hello** & [world](https://example.org).\n\nSecond paragraph.", "Hello & world. [...]"}, + {"HTML and entities", "日本語 & 'quotes'.\n\nSecond paragraph.", "日本語 & 'quotes'. [...]"}, + {"explicit excerpt", "**Before**Hidden\n\nSecond paragraph.", "Before [...]"}, + } { + t.Run(tc.name, func(t *testing.T) { + post := &PublicPost{ + Post: &Post{ID: "testpost01", Content: tc.content, Created: time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)}, + Collection: &CollectionObj{Collection: Collection{Alias: "blue0a6m5c", hostName: cfg.App.Host}}, + } + obj := post.ActivityObject(app) + data, err := json.Marshal(obj) + if err != nil { + t.Fatal(err) + } + var wire struct { + Type string `json:"type"` + Summary *string `json:"summary"` + Content string `json:"content"` + Preview struct { + Content string `json:"content"` + } `json:"preview"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Type != "Article" || wire.Summary == nil { + t.Fatalf("expected Article with summary: %s", data) + } + if *wire.Summary != tc.want { + t.Errorf("summary = %q, want %q", *wire.Summary, tc.want) + } + if !strings.Contains(wire.Preview.Content, "") || !strings.Contains(wire.Content, "") { + t.Errorf("body and preview must retain HTML: %s", data) + } + }) + } +}