code.pablomurad.com

patchwork

Publishes the revisions of your pages as a feed, with notes and readable diffs, written in Go

internal/source/folder/folder_test.go raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
package folder

import (
	"context"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"testing/synctest"
	"time"

	"git.pablomurad.com/pablo/patchwork/internal/config"
	"git.pablomurad.com/pablo/patchwork/internal/engine"
	"git.pablomurad.com/pablo/patchwork/internal/store"
)

func writeFile(t *testing.T, root, rel, body string, mtime time.Time) {
	t.Helper()
	p := filepath.Join(root, filepath.FromSlash(rel))
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(p, mtime, mtime); err != nil {
		t.Fatal(err)
	}
}

func TestListMatchesIncludeAndExclude(t *testing.T) {
	root := t.TempDir()
	old := time.Now().Add(-time.Hour)
	for _, rel := range []string{"a.md", "b.markdown", "notes/deep.md", ".hidden.md", "draft.md~", "upload.md.filepart", ".obsidian/x.md", "image.png"} {
		writeFile(t, root, rel, "x", old)
	}
	m := Matcher{Include: []string{"**/*.md", "**/*.markdown"}, Exclude: []string{".*", "*~", "*.filepart"}}
	files, err := List(root, m)
	if err != nil {
		t.Fatal(err)
	}
	var got []string
	for _, f := range files {
		got = append(got, f.Rel)
	}
	if strings.Join(got, ",") != "a.md,b.markdown,notes/deep.md" {
		t.Fatalf("got %v", got)
	}

	top := Matcher{Include: []string{"*.md"}, Exclude: []string{".*"}}
	files, _ = List(root, top)
	if len(files) != 1 || files[0].Rel != "a.md" {
		t.Fatalf("top-level only: %+v", files)
	}
}

func newRunner(t *testing.T, root string) *Runner {
	t.Helper()
	st, err := store.Open(filepath.Join(t.TempDir(), "pw.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { st.Close() })
	e, err := engine.New(st, config.Default())
	if err != nil {
		t.Fatal(err)
	}
	m := Matcher{Include: []string{"**/*.md"}, Exclude: []string{".*"}}
	return NewRunner(root, m, e, slog.New(slog.NewTextHandler(io.Discard, nil)))
}

func actions(rep Report) string {
	var out []string
	for _, r := range rep.Results {
		out = append(out, r.Path+":"+string(r.Action))
	}
	return strings.Join(out, ",")
}

func TestScanFlow(t *testing.T) {
	ctx := context.Background()
	root := t.TempDir()
	r := newRunner(t, root)
	past := time.Now().Add(-time.Hour)

	writeFile(t, root, "a.md", "One.", past)
	rep, err := r.Scan(ctx, false)
	if err != nil || actions(rep) != "a.md:baseline" {
		t.Fatalf("first scan %q %v %v", actions(rep), rep.Errors, err)
	}

	// Nothing changed on disk: the file isn't even read.
	if rep, _ := r.Scan(ctx, false); len(rep.Results) != 0 {
		t.Fatalf("unchanged scan %q", actions(rep))
	}

	writeFile(t, root, "a.md", "---\nrevision: Expanded.\n---\nOne. Two.", past.Add(time.Minute))
	if rep, _ := r.Scan(ctx, false); actions(rep) != "a.md:published" {
		t.Fatalf("edit scan %q", actions(rep))
	}

	// A file modified a moment ago is still being written.
	writeFile(t, root, "b.md", "Half", time.Now())
	rep, _ = r.Scan(ctx, false)
	if len(rep.Pending) != 1 || rep.Pending[0] != "b.md" {
		t.Fatalf("pending %v", rep.Pending)
	}
}

func TestEmptyFolderDoesNotRemoveEverything(t *testing.T) {
	ctx := context.Background()
	root := t.TempDir()
	r := newRunner(t, root)
	writeFile(t, root, "a.md", "One.", time.Now().Add(-time.Hour))
	r.Scan(ctx, false)
	os.Remove(filepath.Join(root, "a.md"))
	for i := 0; i < 3; i++ {
		rep, _ := r.Scan(ctx, false)
		if len(rep.Results) != 0 || len(rep.Errors) != 1 {
			t.Fatalf("scan %d: %q %v", i, actions(rep), rep.Errors)
		}
	}
}

func TestRemovalNeedsTwoScans(t *testing.T) {
	ctx := context.Background()
	root := t.TempDir()
	r := newRunner(t, root)
	past := time.Now().Add(-time.Hour)
	writeFile(t, root, "a.md", "One.", past)
	writeFile(t, root, "b.md", "Two.", past)
	r.Scan(ctx, false)
	os.Remove(filepath.Join(root, "b.md"))
	if rep, _ := r.Scan(ctx, false); len(rep.Results) != 0 {
		t.Fatalf("first miss %q", actions(rep))
	}
	if rep, _ := r.Scan(ctx, false); actions(rep) != "b.md:removed" {
		t.Fatalf("second miss %q", actions(rep))
	}
}

// TestWatchDebounce runs the watch loop on a fake clock: a burst of events
// produces one scan after the debounce, and polling scans on its own.
func TestWatchDebounce(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		root := t.TempDir()
		r := newRunner(t, root)
		r.Now = time.Now // synctest's fake clock
		// fsnotify blocks in real syscalls, which a synctest bubble can't
		// wait on; the loop's timing is what's under test here.
		r.PollOnly = true
		writeFile(t, root, "a.md", "One.", time.Now().Add(-time.Hour))

		ctx, cancel := context.WithCancel(context.Background())
		scans := 0
		done := make(chan struct{})
		go func() {
			r.Watch(ctx, 3*time.Second, time.Minute, func(Report, error) { scans++ })
			close(done)
		}()
		synctest.Wait()
		if scans != 1 {
			t.Fatalf("initial scans %d", scans)
		}
		time.Sleep(time.Minute)
		synctest.Wait()
		if scans != 2 {
			t.Fatalf("after one poll: %d", scans)
		}
		cancel()
		<-done
	})
}