package support import ( "os" "path/filepath" "sync" "testing" ) // statePath returns a path inside a fresh temp directory for a store file. func statePath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "support.json") } func TestOpenMissingReturnsEmpty(t *testing.T) { s, err := Open(statePath(t)) if err != nil { t.Fatalf("Open missing: %v", err) } if _, ok := s.Get(42); ok { t.Error("Get on an empty store returned a record") } } func TestOpenCorruptReturnsError(t *testing.T) { path := statePath(t) if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { t.Fatalf("write corrupt: %v", err) } if _, err := Open(path); err == nil { t.Fatal("Open on a corrupt file: expected an error, got nil") } } func TestSetTopicPersistsAndReloads(t *testing.T) { path := statePath(t) s := New(path) if err := s.SetTopic(7, 100, 101, "Ann", "Lee", "annlee"); err != nil { t.Fatalf("SetTopic: %v", err) } // In-memory view. got, ok := s.Get(7) if !ok || got.TopicID != 100 || got.HeaderMsgID != 101 || got.Username != "annlee" { t.Fatalf("Get = %+v ok=%v, want topic 100 / header 101 / annlee", got, ok) } if got.CreatedAt.IsZero() { t.Error("CreatedAt not set on first contact") } if uid, ok := s.ByTopic(100); !ok || uid != 7 { t.Errorf("ByTopic(100) = %d ok=%v, want 7/true", uid, ok) } // Reloaded from disk. reload, err := Open(path) if err != nil { t.Fatalf("Open: %v", err) } got, ok = reload.Get(7) if !ok || got.TopicID != 100 || got.FirstName != "Ann" { t.Fatalf("reloaded Get = %+v ok=%v, want topic 100 / Ann", got, ok) } if uid, ok := reload.ByTopic(100); !ok || uid != 7 { t.Errorf("reloaded ByTopic(100) = %d ok=%v, want 7/true", uid, ok) } } func TestSetTopicRecreateReindexes(t *testing.T) { s := New(statePath(t)) if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { t.Fatalf("SetTopic: %v", err) } if err := s.SetTopic(7, 200, 201, "Ann", "", ""); err != nil { t.Fatalf("SetTopic recreate: %v", err) } if _, ok := s.ByTopic(100); ok { t.Error("ByTopic(100) still resolves after recreate; the stale topic was not dropped") } if uid, ok := s.ByTopic(200); !ok || uid != 7 { t.Errorf("ByTopic(200) = %d ok=%v, want 7/true", uid, ok) } } // TestBlockSurvivesTopicRecreate is the field-isolation guarantee: a topic recreate // (SetTopic) must not clear a block set concurrently from the buttons. func TestBlockSurvivesTopicRecreate(t *testing.T) { s := New(statePath(t)) if err := s.SetTopic(7, 100, 101, "Ann", "", "annlee"); err != nil { t.Fatalf("SetTopic: %v", err) } if err := s.SetBlocked(7, true); err != nil { t.Fatalf("SetBlocked: %v", err) } if err := s.SetTopic(7, 200, 201, "Ann", "", "annlee"); err != nil { t.Fatalf("SetTopic recreate: %v", err) } if !s.Blocked(7) { t.Error("block was cleared by a topic recreate") } } func TestAppendAndClearMsgs(t *testing.T) { s := New(statePath(t)) if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { t.Fatalf("SetTopic: %v", err) } for _, id := range []int{500, 501, 502} { if err := s.AppendMsg(7, id); err != nil { t.Fatalf("AppendMsg %d: %v", id, err) } } got, _ := s.Get(7) if len(got.RelayedMsgIDs) != 3 { t.Fatalf("RelayedMsgIDs = %v, want 3 ids", got.RelayedMsgIDs) } ids, err := s.ClearMsgs(7) if err != nil { t.Fatalf("ClearMsgs: %v", err) } if len(ids) != 3 || ids[0] != 500 || ids[2] != 502 { t.Errorf("ClearMsgs = %v, want [500 501 502]", ids) } got, _ = s.Get(7) if len(got.RelayedMsgIDs) != 0 { t.Errorf("RelayedMsgIDs after clear = %v, want empty", got.RelayedMsgIDs) } if got.HeaderMsgID != 101 || got.TopicID != 100 { t.Errorf("clear disturbed the topic/header: %+v", got) } // A second clear is a no-op. if ids, err := s.ClearMsgs(7); err != nil || ids != nil { t.Errorf("second ClearMsgs = %v, %v, want nil, nil", ids, err) } } func TestGetReturnsDetachedCopy(t *testing.T) { s := New(statePath(t)) if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { t.Fatalf("SetTopic: %v", err) } if err := s.AppendMsg(7, 500); err != nil { t.Fatalf("AppendMsg: %v", err) } got, _ := s.Get(7) got.RelayedMsgIDs[0] = 999 // mutate the copy again, _ := s.Get(7) if again.RelayedMsgIDs[0] != 500 { t.Error("Get returned a slice that aliases the store's state") } } // TestConcurrentMutationsSafe exercises the mutex under parallel writers; run with // -race to catch data races. func TestConcurrentMutationsSafe(t *testing.T) { s := New(statePath(t)) if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil { t.Fatalf("SetTopic: %v", err) } var wg sync.WaitGroup for i := range 20 { wg.Add(1) go func(n int) { defer wg.Done() _ = s.AppendMsg(7, 1000+n) _ = s.SetBlocked(7, n%2 == 0) _, _ = s.Get(7) _, _ = s.ByTopic(100) }(i) } wg.Wait() got, ok := s.Get(7) if !ok || len(got.RelayedMsgIDs) != 20 { t.Errorf("after concurrent appends: %d ids, want 20", len(got.RelayedMsgIDs)) } }