Antonio Mika
·
2026-10-11
1package postfs
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "slices"
11 "testing"
12 "time"
13
14 "github.com/picosh/pico/pkg/db"
15 "github.com/picosh/pico/pkg/db/stub"
16 "github.com/picosh/pico/pkg/pssh"
17 sendutils "github.com/picosh/pico/pkg/send/utils"
18 "golang.org/x/crypto/ssh"
19)
20
21// extRename accepts .md and .lxt files, and tags each with its extension.
22// meta runs while the renamed post is made, as a save from another session
23// could.
24func extRename(meta func()) RenameFunc {
25 return func(cur, next *db.Post) ([]string, error) {
26 ext := filepath.Ext(next.Filename)
27 if ext != ".md" && ext != ".lxt" {
28 return nil, fmt.Errorf("%s: invalid extension", next.Filename)
29 }
30 if meta != nil {
31 meta()
32 }
33 next.Tags = []string{ext}
34 next.Title = next.Slug
35 next.Hidden = cur.Hidden
36 return []string{next.Slug + ext}, nil
37 }
38}
39
40func entry(name string) *sendutils.FileEntry {
41 return &sendutils.FileEntry{Filepath: "/" + name}
42}
43
44func newPostsSession(t *testing.T) (*pssh.SSHServerConnSession, *stub.PostsDB) {
45 t.Helper()
46 logger := slog.New(slog.DiscardHandler)
47 user := &db.User{ID: "user-1", Name: "tester"}
48 session := &pssh.SSHServerConnSession{
49 SSHServerConn: &pssh.SSHServerConn{
50 Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
51 Logger: logger,
52 },
53 Ctx: context.Background(),
54 }
55 pssh.SetUser(session, user)
56 return session, stub.NewPostsDB(logger, user)
57}
58
59func insertPost(t *testing.T, posts *stub.PostsDB, filename, text string, hidden bool) *db.Post {
60 t.Helper()
61 p := NewPost(filename, []byte(text))
62 publish := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
63 p.UserID, p.Space, p.Hidden, p.PublishAt, p.UpdatedAt = "user-1", "prose", hidden, &publish, &publish
64 created, err := posts.InsertPost(p)
65 if err != nil {
66 t.Fatal(err)
67 }
68 return created
69}
70
71func TestPostRename(t *testing.T) {
72 session, posts := newPostsSession(t)
73 a := insertPost(t, posts, "a.md", "# a", true)
74 b := insertPost(t, posts, "b.md", "# b", false)
75 insertPost(t, posts, "c.md", "# c", false)
76 rename := func(from, to string) error {
77 return RenamePost(session, posts, "prose", entry(from), entry(to), extRename(nil))
78 }
79
80 if err := rename("a.md", "a.lxt"); err != nil {
81 t.Fatal(err)
82 }
83 got, err := posts.FindPostWithFilename("a.lxt", "user-1", "prose")
84 if err != nil {
85 t.Fatal(err)
86 }
87 if got.ID != a.ID || !got.PublishAt.Equal(*a.PublishAt) || !got.Hidden || got.Text != "# a" || got.MimeType == a.MimeType {
88 t.Errorf("renamed post = %+v, was %+v", got, a)
89 }
90
91 // c.lxt has the slug c.md has.
92 if err := rename("a.lxt", "c.lxt"); !errors.Is(err, os.ErrExist) {
93 t.Errorf("rename onto a used slug: %v", err)
94 }
95 if err := rename("a.lxt", "a.txt"); err == nil {
96 t.Error("rename to an invalid name succeeded")
97 }
98 if err := rename("missing.md", "m.md"); !errors.Is(err, os.ErrNotExist) {
99 t.Errorf("rename of a missing post: %v", err)
100 }
101
102 if err := rename("a.lxt", "b.md"); err != nil {
103 t.Fatal(err)
104 }
105 got, err = posts.FindPostWithFilename("b.md", "user-1", "prose")
106 if err != nil || got.ID != a.ID || got.Text != "# a" {
107 t.Errorf("b.md = %+v, %v", got, err)
108 }
109 if _, err := posts.FindPost(b.ID); err == nil {
110 t.Error("replaced post still exists")
111 }
112 all, _ := posts.FindAllPostsByUser("user-1", "prose")
113 if len(all) != 2 {
114 t.Errorf("posts = %d, want 2", len(all))
115 }
116}
117
118func TestPostRenameRelations(t *testing.T) {
119 session, posts := newPostsSession(t)
120 a := insertPost(t, posts, "a.md", "# a", false)
121 if err := RenamePost(session, posts, "prose", entry("a.md"), entry("a.lxt"), extRename(nil)); err != nil {
122 t.Fatal(err)
123 }
124 got, err := posts.FindPost(a.ID)
125 if err != nil {
126 t.Fatal(err)
127 }
128 if !slices.Equal(got.Tags, []string{".lxt"}) || !slices.Equal(posts.Aliases(a.ID), []string{"a.lxt"}) {
129 t.Errorf("tags %q and aliases %q, want the .lxt ones", got.Tags, posts.Aliases(a.ID))
130 }
131}
132
133func TestPostRenameConcurrentSave(t *testing.T) {
134 for _, tt := range []struct {
135 name string
136 // saves is how many renames a save interrupts.
137 saves int
138 // post is the post that is saved.
139 post string
140 want error
141 }{
142 {name: "source saved once", saves: 1, post: "a.md"},
143 {name: "replaced post saved once", saves: 1, post: "b.md"},
144 {name: "source saved on every try", saves: 3, post: "a.md", want: db.ErrPostChanged},
145 {name: "replaced post saved on every try", saves: 3, post: "b.md", want: db.ErrPostChanged},
146 } {
147 t.Run(tt.name, func(t *testing.T) {
148 session, posts := newPostsSession(t)
149 a := insertPost(t, posts, "a.md", "# a", true)
150 b := insertPost(t, posts, "b.md", "# b", false)
151 saves := 0
152 save := func() {
153 if saves == tt.saves {
154 return
155 }
156 saves++
157 p, err := posts.FindPostWithFilename(tt.post, "user-1", "prose")
158 if err != nil {
159 t.Fatal(err)
160 }
161 p.Text = fmt.Sprintf("save %d", saves)
162 now := time.Now()
163 p.UpdatedAt = &now
164 if _, err := posts.UpdatePost(p); err != nil {
165 t.Fatal(err)
166 }
167 }
168 err := RenamePost(session, posts, "prose", entry("a.md"), entry("b.md"), extRename(save))
169 if !errors.Is(err, tt.want) {
170 t.Fatalf("rename: %v, want %v", err, tt.want)
171 }
172 lastSave := fmt.Sprintf("save %d", saves)
173 if tt.want != nil {
174 // Both posts are where they were, with the last save.
175 for _, p := range []*db.Post{a, b} {
176 got, err := posts.FindPost(p.ID)
177 if err != nil {
178 t.Fatalf("%s: %v", p.Filename, err)
179 }
180 want := p.Text
181 if p.Filename == tt.post {
182 want = lastSave
183 }
184 if got.Filename != p.Filename || got.Text != want {
185 t.Errorf("%s = %s %q, want %q", p.Filename, got.Filename, got.Text, want)
186 }
187 }
188 return
189 }
190 got, err := posts.FindPostWithFilename("b.md", "user-1", "prose")
191 if err != nil {
192 t.Fatal(err)
193 }
194 want := "# a"
195 if tt.post == "a.md" {
196 want = lastSave
197 }
198 if got.ID != a.ID || got.Text != want || !got.Hidden {
199 t.Errorf("b.md = %s %q hidden %v, want %s %q hidden", got.ID, got.Text, got.Hidden, a.ID, want)
200 }
201 })
202 }
203}
204
205func TestPostRenameMovedDestination(t *testing.T) {
206 session, posts := newPostsSession(t)
207 a := insertPost(t, posts, "a.md", "# a", false)
208 b := insertPost(t, posts, "b.md", "# b", false)
209 moved := false
210 move := func() {
211 if moved {
212 return
213 }
214 moved = true
215 cur, err := posts.FindPost(b.ID)
216 if err != nil {
217 t.Fatal(err)
218 }
219 next := *cur
220 next.Filename, next.Slug = "f.md", "f"
221 if _, err := posts.RenamePost(cur, &next, nil, nil, nil); err != nil {
222 t.Fatal(err)
223 }
224 }
225 if err := RenamePost(session, posts, "prose", entry("a.md"), entry("b.md"), extRename(move)); err != nil {
226 t.Fatal(err)
227 }
228 for name, id := range map[string]string{"b.md": a.ID, "f.md": b.ID} {
229 if got, err := posts.FindPostWithFilename(name, "user-1", "prose"); err != nil || got.ID != id {
230 t.Errorf("%s = %+v, %v, want post %s", name, got, err, id)
231 }
232 }
233}