package main import ( "encoding/json" "net/http" "strings" "testing" ) // Muting a chat is a per-user marker synced via getThreads, and its whole // server-side effect is subtractive: the push fan-out drops every one of the // muter's devices for that thread. Nobody else's list or pushes change. func TestThreadMuted(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, _ := joinedMember(t, srv, "Alice") tokB, uidB, _ := joinedMember(t, srv, "Bob") _ = tokB tid, err := srv.db.createThread("group", "", []ThreadMemberKey{ {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}, }) if err != nil { t.Fatalf("createThread: %v", err) } post := func(path, body string) int { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } resp.Body.Close() return resp.StatusCode } mutedForAlice := func() bool { resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA) if err != nil { t.Fatalf("getThreads: %v", err) } defer resp.Body.Close() var out struct { Threads []Thread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } for _, th := range out.Threads { if th.ID == tid { return th.Muted } } t.Fatalf("thread %q missing from Alice's list", tid) return false } if mutedForAlice() { t.Fatal("fresh thread should not be muted") } if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokA+`","muted":true}`); code != 200 { t.Fatalf("mute: got %d", code) } if !mutedForAlice() { t.Fatal("mute should stick and sync via getThreads") } // The push recipient set loses the muter — and only the muter. set := map[string]bool{uidA: true, uidB: true} srv.dropMutedRecipients(tid, set) if set[uidA] || !set[uidB] { t.Fatalf("muted member should drop from push recipients, others stay: %v", set) } if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokA+`","muted":false}`); code != 200 { t.Fatalf("unmute: got %d", code) } if mutedForAlice() { t.Fatal("unmute should clear the marker") } set = map[string]bool{uidA: true, uidB: true} srv.dropMutedRecipients(tid, set) if !set[uidA] { t.Fatal("after unmuting, pushes should reach the member again") } // Non-members can't mute a thread they're not in. tokC, _, _ := joinedMember(t, srv, "Carol") if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokC+`","muted":true}`); code != 403 { t.Fatalf("non-member mute should be 403, got %d", code) } } // The prefs blob (muted people) round-trips opaquely and CASes like the // nicknames blob: a write from a stale version 409s with the current version // instead of clobbering another device's write. func TestPrefsBlobRoundTrip(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Alice") get := func() (string, int64) { resp, err := http.Get(ts.URL + "/api/prefs?token=" + tok) if err != nil { t.Fatalf("get prefs: %v", err) } defer resp.Body.Close() var out struct { PrefsBlob string `json:"prefs_blob"` Version int64 `json:"version"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } return out.PrefsBlob, out.Version } post := func(body string) (int, int64) { resp, err := http.Post(ts.URL+"/api/prefs", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post prefs: %v", err) } defer resp.Body.Close() var out struct { Version int64 `json:"version"` } _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out.Version } if blob, v := get(); blob != "" || v != 0 { t.Fatalf("fresh prefs should be empty at version 0, got %q v%d", blob, v) } if code, v := post(`{"token":"` + tok + `","prefs_blob":"sealed1","version":0}`); code != 200 || v != 1 { t.Fatalf("first write: got %d v%d", code, v) } if blob, v := get(); blob != "sealed1" || v != 1 { t.Fatalf("round trip: got %q v%d", blob, v) } // Stale base: another device already wrote v1, so a writer holding v0 loses. if code, v := post(`{"token":"` + tok + `","prefs_blob":"stale","version":0}`); code != 409 || v != 1 { t.Fatalf("stale write should 409 with current version, got %d v%d", code, v) } if blob, _ := get(); blob != "sealed1" { t.Fatalf("stale write must not clobber, got %q", blob) } // Versionless write (pre-versioning client) stays unconditional. if code, _ := post(`{"token":"` + tok + `","prefs_blob":"sealed2"}`); code != 200 { t.Fatalf("versionless write: got %d", code) } if blob, v := get(); blob != "sealed2" || v != 2 { t.Fatalf("after versionless write: got %q v%d", blob, v) } }