| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "sync" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | // changelogClean mirrors web/static/format.js changelogParagraphs β these cases |
| 10 | // are shared with test/format.test.mjs so the two sides can't drift. |
| 11 | func TestChangelogClean(t *testing.T) { |
| 12 | cases := []struct{ name, in, want string }{ |
| 13 | {"drops the π€ authorship marker", "π€ Unread dots now sync across your devices.", "Unread dots now sync across your devices."}, |
| 14 | {"drops a person marker", "π¨ Hand-written note.", "Hand-written note."}, |
| 15 | { |
| 16 | "collapses hard wraps but keeps paragraph breaks", |
| 17 | "π€ Your space can now keep itself up\nto date. Admins get a new\nUpdates section.\n\nUpdates take seconds.", |
| 18 | "Your space can now keep itself up to date. Admins get a new Updates section.\n\nUpdates take seconds.", |
| 19 | }, |
| 20 | {"no marker passes through", "Plain note, no marker.", "Plain note, no marker."}, |
| 21 | {"blank collapses to empty", " ", ""}, |
| 22 | } |
| 23 | for _, c := range cases { |
| 24 | if got := changelogClean(c.in); got != c.want { |
| 25 | t.Errorf("%s: got %q, want %q", c.name, got, c.want) |
| 26 | } |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestAnnounceChangelog(t *testing.T) { |
| 31 | _, s := newTestServer(t) |
| 32 | if len(s.changelog) < 2 { |
| 33 | t.Fatal("embedded changelog unexpectedly small") |
| 34 | } |
| 35 | |
| 36 | // First run: no watermark β baseline silently, announce nothing. |
| 37 | fresh, sent := s.announceChangelog() |
| 38 | if fresh != 0 || sent != 0 { |
| 39 | t.Fatalf("first run should baseline: fresh=%d sent=%d", fresh, sent) |
| 40 | } |
| 41 | raw, ok, _ := s.db.getConfig(changelogAnnouncedKey) |
| 42 | if !ok { |
| 43 | t.Fatal("baseline did not write the watermark") |
| 44 | } |
| 45 | var ids []string |
| 46 | if err := json.Unmarshal([]byte(raw), &ids); err != nil || len(ids) != len(s.changelog) { |
| 47 | t.Fatalf("watermark should hold every entry id: %d != %d (%v)", len(ids), len(s.changelog), err) |
| 48 | } |
| 49 | |
| 50 | // Steady state: nothing new β nothing announced. |
| 51 | if fresh, _ = s.announceChangelog(); fresh != 0 { |
| 52 | t.Fatalf("no new entries but fresh=%d", fresh) |
| 53 | } |
| 54 | |
| 55 | // A "deploy": the stored watermark misses the two newest entries (as if this |
| 56 | // binary shipped them). They announce, and the watermark catches up. |
| 57 | trimmed, _ := json.Marshal(ids[:len(ids)-2]) |
| 58 | if err := s.db.setConfig(changelogAnnouncedKey, string(trimmed)); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | fresh, sent = s.announceChangelog() |
| 62 | if fresh != 2 { |
| 63 | t.Fatalf("expected 2 fresh entries, got %d", fresh) |
| 64 | } |
| 65 | if sent != 0 { // no push subscriptions in the test DB |
| 66 | t.Fatalf("expected 0 sends without subscriptions, got %d", sent) |
| 67 | } |
| 68 | raw, _, _ = s.db.getConfig(changelogAnnouncedKey) |
| 69 | var after []string |
| 70 | _ = json.Unmarshal([]byte(raw), &after) |
| 71 | if len(after) != len(s.changelog) { |
| 72 | t.Fatalf("watermark should catch up: %d != %d", len(after), len(s.changelog)) |
| 73 | } |
| 74 | |
| 75 | // A corrupt watermark re-baselines rather than announcing the backlog. |
| 76 | _ = s.db.setConfig(changelogAnnouncedKey, "not-json") |
| 77 | if fresh, _ = s.announceChangelog(); fresh != 0 { |
| 78 | t.Fatalf("corrupt watermark should re-baseline, got fresh=%d", fresh) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestAdminUserIDs(t *testing.T) { |
| 83 | _, s := newTestServer(t) |
| 84 | a, _ := s.db.createUser("Ada") |
| 85 | b, _ := s.db.createUser("Bob") |
| 86 | if err := s.db.setUserRole(a, "admin"); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | admins, err := s.db.adminUserIDs() |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if len(admins) != 1 || admins[0] != a { |
| 94 | t.Fatalf("want [%s], got %v", a, admins) |
| 95 | } |
| 96 | _ = b // stays a member β must not appear |
| 97 | } |
| 98 | |
| 99 | // stubAPNs records sendAlert calls so apnsAnnounce's admin filtering is |
| 100 | // observable without any network. |
| 101 | type stubAPNs struct { |
| 102 | mu sync.Mutex |
| 103 | alerts []string // "apnsToken|title|body" |
| 104 | } |
| 105 | |
| 106 | func (s *stubAPNs) send(string, bool, string, string, int64, int64) error { return nil } |
| 107 | func (s *stubAPNs) sendAlert(deviceToken string, _ bool, title, body string) error { |
| 108 | s.mu.Lock() |
| 109 | defer s.mu.Unlock() |
| 110 | s.alerts = append(s.alerts, deviceToken+"|"+title+"|"+body) |
| 111 | return nil |
| 112 | } |
| 113 | |
| 114 | func TestAPNsAnnounceReachesOnlyAdmins(t *testing.T) { |
| 115 | _, s := newTestServer(t) |
| 116 | stub := &stubAPNs{} |
| 117 | s.apns = stub |
| 118 | |
| 119 | adminTok := mintDeviceToken(t, s.db, "Ada") |
| 120 | memberTok := mintDeviceToken(t, s.db, "Bob") |
| 121 | adminUID, _ := s.db.deviceTokenUser(adminTok) |
| 122 | if err := s.db.setUserRole(adminUID, "admin"); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if err := s.db.addAPNsToken(APNsToken{APNsToken: "apns-ada", Token: adminTok}); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if err := s.db.addAPNsToken(APNsToken{APNsToken: "apns-bob", Token: memberTok}); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | |
| 132 | sent := s.apnsAnnounce("What's new", "Notes.", map[string]bool{adminUID: true}) |
| 133 | if sent != 1 { |
| 134 | t.Fatalf("sent=%d, want 1 (admin only)", sent) |
| 135 | } |
| 136 | if len(stub.alerts) != 1 || stub.alerts[0] != "apns-ada|What's new|Notes." { |
| 137 | t.Fatalf("unexpected alerts: %v", stub.alerts) |
| 138 | } |
| 139 | } |
| 140 | |