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 }