Recently Written · git

recentlywritten

my writings (done recently)

git clone https://github.com/equwal/recentlywritten

Log | Files | Refs


accounts/notify_test.go (6480 bytes)

1 package main
2 
3 import (
4 	"fmt"
5 	"strings"
6 	"testing"
7 	"time"
8 
9 	"pgregory.net/rapid"
10 )
11 
12 func TestNotifyFirstRunSendsNoOldPosts(t *testing.T) {
13 	e := newTestEnv(t, oldPosts)
14 	e.signUp(t, "a@example.com")
15 	e.advance(time.Minute)
16 	if err := e.app.notify(); err != nil {
17 		t.Fatal(err)
18 	}
19 	if len(e.mail.sent) != 0 {
20 		t.Fatalf("sent %d messages for posts that were on the site before the first run", len(e.mail.sent))
21 	}
22 }
23 
24 func TestNotifySendsNewPostOneTime(t *testing.T) {
25 	e := newTestEnv(t, oldPosts)
26 	if err := e.app.notify(); err != nil { // first run records the old posts
27 		t.Fatal(err)
28 	}
29 	u := e.signUp(t, "a@example.com")
30 	e.advance(time.Hour)
31 	newPost := Post{Date: "2026-09-02", Slug: "new-post", Tags: []string{"articles"}, Title: "New post"}
32 	e.writeIndex(t, append([]Post{newPost}, oldPosts...))
33 
34 	for range 3 {
35 		if err := e.app.notify(); err != nil {
36 			t.Fatal(err)
37 		}
38 		e.advance(time.Minute)
39 	}
40 	if len(e.mail.sent) != 1 {
41 		t.Fatalf("sent %d messages, want 1", len(e.mail.sent))
42 	}
43 	m := e.mail.sent[0]
44 	if m.To != "a@example.com" || m.Subject != "New post: New post" {
45 		t.Errorf("message = %+v", m)
46 	}
47 	if !strings.Contains(m.Body, "https://example.com/new-post.html") {
48 		t.Errorf("body has no link to the post:\n%s", m.Body)
49 	}
50 	if strings.Contains(m.Body, "old-article") {
51 		t.Errorf("body has an old post:\n%s", m.Body)
52 	}
53 	wantUnsub := "https://example.com/account/unsubscribe?t=" + u.UnsubToken
54 	if m.Unsubscribe != wantUnsub || !strings.Contains(m.Body, wantUnsub) {
55 		t.Errorf("unsubscribe link missing: header %q, body:\n%s", m.Unsubscribe, m.Body)
56 	}
57 	if !strings.Contains(m.Body, "https://subread.space/\n") {
58 		t.Errorf("body has no link to SubRead:\n%s", m.Body)
59 	}
60 }
61 
62 func TestNotifyRetriesAfterSendFailure(t *testing.T) {
63 	e := newTestEnv(t, oldPosts)
64 	if err := e.app.notify(); err != nil {
65 		t.Fatal(err)
66 	}
67 	e.signUp(t, "a@example.com")
68 	e.advance(time.Hour)
69 	e.writeIndex(t, append([]Post{{Date: "2026-09-02", Slug: "new-post", Title: "New post"}}, oldPosts...))
70 
71 	e.mail.fail = true
72 	if err := e.app.notify(); err == nil {
73 		t.Fatal("notify returned no error when the send failed")
74 	}
75 	e.mail.fail = false
76 	for range 2 {
77 		if err := e.app.notify(); err != nil {
78 			t.Fatal(err)
79 		}
80 	}
81 	if len(e.mail.sent) != 1 {
82 		t.Fatalf("sent %d messages, want 1", len(e.mail.sent))
83 	}
84 }
85 
86 func TestNotifySkipsPostsFromBeforeSignUp(t *testing.T) {
87 	e := newTestEnv(t, oldPosts)
88 	if err := e.app.notify(); err != nil {
89 		t.Fatal(err)
90 	}
91 	e.advance(time.Hour)
92 	e.writeIndex(t, append([]Post{{Date: "2026-09-02", Slug: "earlier", Title: "Earlier"}}, oldPosts...))
93 	if err := e.app.notify(); err != nil { // no users yet
94 		t.Fatal(err)
95 	}
96 	e.advance(time.Hour)
97 	e.signUp(t, "late@example.com")
98 	e.advance(time.Hour)
99 	if err := e.app.notify(); err != nil {
100 		t.Fatal(err)
101 	}
102 	if len(e.mail.sent) != 0 {
103 		t.Fatalf("a new user got a post from before the sign-up: %+v", e.mail.sent)
104 	}
105 }
106 
107 func TestNotifyObeysSettings(t *testing.T) {
108 	e := newTestEnv(t, oldPosts)
109 	if err := e.app.notify(); err != nil {
110 		t.Fatal(err)
111 	}
112 	u := e.signUp(t, "a@example.com")
113 	if err := saveSettings(e.app.db, u.ID, true, []string{"projects"}); err != nil {
114 		t.Fatal(err)
115 	}
116 	e.advance(time.Hour)
117 	e.writeIndex(t, append([]Post{
118 		{Date: "2026-09-02", Slug: "proj", Tags: []string{"projects"}, Title: "Proj"},
119 		{Date: "2026-09-02", Slug: "art", Tags: []string{"articles"}, Title: "Art"},
120 	}, oldPosts...))
121 	if err := e.app.notify(); err != nil {
122 		t.Fatal(err)
123 	}
124 	if len(e.mail.sent) != 1 || strings.Contains(e.mail.sent[0].Body, "proj.html") {
125 		t.Fatalf("muted tag was sent: %+v", e.mail.sent)
126 	}
127 
128 	// Unmute the tag. The muted post must not arrive later.
129 	if err := saveSettings(e.app.db, u.ID, true, nil); err != nil {
130 		t.Fatal(err)
131 	}
132 	e.advance(time.Hour)
133 	if err := e.app.notify(); err != nil {
134 		t.Fatal(err)
135 	}
136 	if len(e.mail.sent) != 1 {
137 		t.Fatalf("unmute sent an old post: %+v", e.mail.sent[1:])
138 	}
139 
140 	// With email off, nothing goes out.
141 	if err := saveSettings(e.app.db, u.ID, false, nil); err != nil {
142 		t.Fatal(err)
143 	}
144 	e.advance(time.Hour)
145 	e.writeIndex(t, append([]Post{{Date: "2026-09-03", Slug: "third", Title: "Third"}}, oldPosts...))
146 	if err := e.app.notify(); err != nil {
147 		t.Fatal(err)
148 	}
149 	if len(e.mail.sent) != 1 {
150 		t.Fatalf("email off, but sent: %+v", e.mail.sent[1:])
151 	}
152 }
153 
154 // TestNotifyNoDuplicatesProperty runs random sequences of new posts, new
155 // users, send failures, and notify runs. At the end no user has a post two
156 // times, and each user has each post that notify first saw at or after
157 // the sign-up.
158 func TestNotifyNoDuplicatesProperty(t *testing.T) {
159 	rapid.Check(t, func(rt *rapid.T) {
160 		e := newTestEnv(t, oldPosts)
161 		if err := e.app.notify(); err != nil { // the first run records the old posts
162 			rt.Fatal(err)
163 		}
164 		index := append([]Post(nil), oldPosts...)
165 		seenAt := map[string]time.Time{}   // slug -> time of the first notify run that saw it
166 		joinedAt := map[string]time.Time{} // email -> sign-up time
167 		run := func() {
168 			for _, p := range index {
169 				if _, ok := seenAt[p.Slug]; !ok {
170 					seenAt[p.Slug] = e.clock.now()
171 				}
172 			}
173 			_ = e.app.notify()
174 		}
175 		for _, p := range oldPosts {
176 			seenAt[p.Slug] = time.Time{}
177 		}
178 
179 		steps := rapid.SliceOfN(rapid.IntRange(0, 3), 1, 30).Draw(rt, "steps")
180 		for i, step := range steps {
181 			e.advance(time.Minute)
182 			switch step {
183 			case 0:
184 				p := Post{Date: "2026-09-02", Slug: fmt.Sprintf("p%d", i), Title: fmt.Sprintf("P%d", i)}
185 				index = append([]Post{p}, index...)
186 				e.writeIndex(rt, index)
187 			case 1:
188 				email := fmt.Sprintf("u%d@example.com", i)
189 				e.signUp(rt, email)
190 				joinedAt[email] = e.clock.now()
191 			case 2:
192 				e.mail.fail = !e.mail.fail
193 			case 3:
194 				run()
195 			}
196 		}
197 		e.advance(time.Minute)
198 		e.mail.fail = false
199 		run()
200 
201 		got := map[string]map[string]int{} // email -> slug -> count
202 		for _, m := range e.mail.sent {
203 			if got[m.To] == nil {
204 				got[m.To] = map[string]int{}
205 			}
206 			for _, line := range strings.Split(m.Body, "\n") {
207 				if s, ok := strings.CutPrefix(line, "https://example.com/"); ok {
208 					got[m.To][strings.TrimSuffix(s, ".html")]++
209 				}
210 			}
211 		}
212 		for email, joined := range joinedAt {
213 			want := 0
214 			for _, p := range index {
215 				if seenAt[p.Slug].Before(joined) {
216 					continue
217 				}
218 				want++
219 				if n := got[email][p.Slug]; n != 1 {
220 					rt.Fatalf("%s got %s %d times, want 1", email, p.Slug, n)
221 				}
222 			}
223 			if len(got[email]) != want {
224 				rt.Fatalf("%s got %v, want %d posts", email, got[email], want)
225 			}
226 		}
227 	})
228 }