main pico / pkg / postfs / rename_test.go
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}