| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // Muting a chat is a per-user marker synced via getThreads, and its whole |
| 11 | // server-side effect is subtractive: the push fan-out drops every one of the |
| 12 | // muter's devices for that thread. Nobody else's list or pushes change. |
| 13 | func TestThreadMuted(t *testing.T) { |
| 14 | ts, srv := newTestServer(t) |
| 15 | |
| 16 | tokA, uidA, _ := joinedMember(t, srv, "Alice") |
| 17 | tokB, uidB, _ := joinedMember(t, srv, "Bob") |
| 18 | _ = tokB |
| 19 | tid, err := srv.db.createThread("group", "", []ThreadMemberKey{ |
| 20 | {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}, |
| 21 | }) |
| 22 | if err != nil { |
| 23 | t.Fatalf("createThread: %v", err) |
| 24 | } |
| 25 | |
| 26 | post := func(path, body string) int { |
| 27 | resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) |
| 28 | if err != nil { |
| 29 | t.Fatalf("post %s: %v", path, err) |
| 30 | } |
| 31 | resp.Body.Close() |
| 32 | return resp.StatusCode |
| 33 | } |
| 34 | mutedForAlice := func() bool { |
| 35 | resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA) |
| 36 | if err != nil { |
| 37 | t.Fatalf("getThreads: %v", err) |
| 38 | } |
| 39 | defer resp.Body.Close() |
| 40 | var out struct { |
| 41 | Threads []Thread `json:"threads"` |
| 42 | } |
| 43 | if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| 44 | t.Fatalf("decode: %v", err) |
| 45 | } |
| 46 | for _, th := range out.Threads { |
| 47 | if th.ID == tid { |
| 48 | return th.Muted |
| 49 | } |
| 50 | } |
| 51 | t.Fatalf("thread %q missing from Alice's list", tid) |
| 52 | return false |
| 53 | } |
| 54 | |
| 55 | if mutedForAlice() { |
| 56 | t.Fatal("fresh thread should not be muted") |
| 57 | } |
| 58 | if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokA+`","muted":true}`); code != 200 { |
| 59 | t.Fatalf("mute: got %d", code) |
| 60 | } |
| 61 | if !mutedForAlice() { |
| 62 | t.Fatal("mute should stick and sync via getThreads") |
| 63 | } |
| 64 | |
| 65 | // The push recipient set loses the muter — and only the muter. |
| 66 | set := map[string]bool{uidA: true, uidB: true} |
| 67 | srv.dropMutedRecipients(tid, set) |
| 68 | if set[uidA] || !set[uidB] { |
| 69 | t.Fatalf("muted member should drop from push recipients, others stay: %v", set) |
| 70 | } |
| 71 | |
| 72 | if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokA+`","muted":false}`); code != 200 { |
| 73 | t.Fatalf("unmute: got %d", code) |
| 74 | } |
| 75 | if mutedForAlice() { |
| 76 | t.Fatal("unmute should clear the marker") |
| 77 | } |
| 78 | set = map[string]bool{uidA: true, uidB: true} |
| 79 | srv.dropMutedRecipients(tid, set) |
| 80 | if !set[uidA] { |
| 81 | t.Fatal("after unmuting, pushes should reach the member again") |
| 82 | } |
| 83 | |
| 84 | // Non-members can't mute a thread they're not in. |
| 85 | tokC, _, _ := joinedMember(t, srv, "Carol") |
| 86 | if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokC+`","muted":true}`); code != 403 { |
| 87 | t.Fatalf("non-member mute should be 403, got %d", code) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // The prefs blob (muted people) round-trips opaquely and CASes like the |
| 92 | // nicknames blob: a write from a stale version 409s with the current version |
| 93 | // instead of clobbering another device's write. |
| 94 | func TestPrefsBlobRoundTrip(t *testing.T) { |
| 95 | ts, srv := newTestServer(t) |
| 96 | tok, _, _ := joinedMember(t, srv, "Alice") |
| 97 | |
| 98 | get := func() (string, int64) { |
| 99 | resp, err := http.Get(ts.URL + "/api/prefs?token=" + tok) |
| 100 | if err != nil { |
| 101 | t.Fatalf("get prefs: %v", err) |
| 102 | } |
| 103 | defer resp.Body.Close() |
| 104 | var out struct { |
| 105 | PrefsBlob string `json:"prefs_blob"` |
| 106 | Version int64 `json:"version"` |
| 107 | } |
| 108 | if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| 109 | t.Fatalf("decode: %v", err) |
| 110 | } |
| 111 | return out.PrefsBlob, out.Version |
| 112 | } |
| 113 | post := func(body string) (int, int64) { |
| 114 | resp, err := http.Post(ts.URL+"/api/prefs", "application/json", strings.NewReader(body)) |
| 115 | if err != nil { |
| 116 | t.Fatalf("post prefs: %v", err) |
| 117 | } |
| 118 | defer resp.Body.Close() |
| 119 | var out struct { |
| 120 | Version int64 `json:"version"` |
| 121 | } |
| 122 | _ = json.NewDecoder(resp.Body).Decode(&out) |
| 123 | return resp.StatusCode, out.Version |
| 124 | } |
| 125 | |
| 126 | if blob, v := get(); blob != "" || v != 0 { |
| 127 | t.Fatalf("fresh prefs should be empty at version 0, got %q v%d", blob, v) |
| 128 | } |
| 129 | if code, v := post(`{"token":"` + tok + `","prefs_blob":"sealed1","version":0}`); code != 200 || v != 1 { |
| 130 | t.Fatalf("first write: got %d v%d", code, v) |
| 131 | } |
| 132 | if blob, v := get(); blob != "sealed1" || v != 1 { |
| 133 | t.Fatalf("round trip: got %q v%d", blob, v) |
| 134 | } |
| 135 | // Stale base: another device already wrote v1, so a writer holding v0 loses. |
| 136 | if code, v := post(`{"token":"` + tok + `","prefs_blob":"stale","version":0}`); code != 409 || v != 1 { |
| 137 | t.Fatalf("stale write should 409 with current version, got %d v%d", code, v) |
| 138 | } |
| 139 | if blob, _ := get(); blob != "sealed1" { |
| 140 | t.Fatalf("stale write must not clobber, got %q", blob) |
| 141 | } |
| 142 | // Versionless write (pre-versioning client) stays unconditional. |
| 143 | if code, _ := post(`{"token":"` + tok + `","prefs_blob":"sealed2"}`); code != 200 { |
| 144 | t.Fatalf("versionless write: got %d", code) |
| 145 | } |
| 146 | if blob, v := get(); blob != "sealed2" || v != 2 { |
| 147 | t.Fatalf("after versionless write: got %q v%d", blob, v) |
| 148 | } |
| 149 | } |
| 150 | |