Files
OwnCord/Server/api/blocks_handler_test.go
Claude 0918f859a0 test: close measured test-coverage gaps across server, client and Rust
Audits what actually has tests, then closes the gaps it found. Full write-up
with before/after numbers in docs/audit-test-coverage-2026-07-25.md.

Measurement first: `go test ./... -coverprofile` (what CI runs) instruments
each package only for itself, so code exercised through another package's
tests reads as uncovered — `service` reported 36.7% against a real 85%. All
analysis here uses -coverpkg=./..., and both views now have Makefile targets.

Features that had zero coverage at every layer:
- user blocking (db + service + the /api/v1/blocks routes)
- auth lockout persistence — the DB round-trip that survives a restart
- plugin install/enable/disable/uninstall and the plugin KV namespace
- event replay bounds (GetMaxEventSeq, PruneEventsOlderThan)
- LiveKit participant_joined webhook (replayed-token guard), the room-service
  client, and proxyWebSocket/copyWS
- ws_proxy.rs and livekit_proxy.rs — pure helpers extracted, matching the
  existing tofu.rs pattern, so cert-pin and header-injection checks are testable

Gaps that were hidden rather than absent:
- Server/admin reported 0.3% coverage with 307 tests passing. TestSpawnDetached_*
  re-execs the test binary; the child inherited GOCOVERDIR and the parent's
  stdout, clobbering the profile and printing "[no tests to run]". Now 71.4%,
  and CI's uploaded artifact is correct.
- vitest.config.ts excluded 2.2k LOC unexplained, including two files that
  already had tests. Trimmed to three entries, each justified inline.
- api.HandleLiveKitHealthForTest re-implemented the handler it claimed to
  expose, so eight call sites tested a copy. Added a hook to the real one.

Two bugs found and pinned rather than silently patched: logctx.WithGroup nests
req_id under the group, and drag-reorder.ts takes one listener ref per channel
but releases one per sidebar, so the count never reaches zero.

Coverage: client 92.93% -> 94.87% statements (3371 -> 3572 tests) even after
un-excluding hidden files; Rust 47 -> 74 tests; Go zero-coverage functions
~70 -> 21, with plugin 61->77%, admin 67->86%, db 76->84%, service 85->91%.

Verified: go vet, all four build-tag variants, go test -race, -tags deadlock,
vitest --coverage, cargo test --lib, cargo clippy --all-targets, playwright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEETs3Vh6sAHHb1jMBL75g
2026-07-25 14:47:21 +00:00

241 lines
7.8 KiB
Go

package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The /api/v1/blocks routes are mounted by MountDMRoutes but were never
// exercised: handleListBlocks, handleBlockUser and handleUnblockUser had no
// test hitting them, so the whole blocking feature was untested from the REST
// edge down to the database. These tests reuse the DM harness
// (newDMTestDB / buildDMRouter / dmCreateToken) since the routes share a mount.
// dmPut issues a PUT against the DM/blocks router. The existing helpers cover
// POST, GET and DELETE only.
func dmPut(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPut, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// decodeBlockedIDs pulls blocked_user_ids out of a GET /api/v1/blocks response.
func decodeBlockedIDs(t *testing.T, rr *httptest.ResponseRecorder) []int64 {
t.Helper()
var body struct {
BlockedUserIDs []int64 `json:"blocked_user_ids"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode blocked_user_ids from %q: %v", rr.Body.String(), err)
}
return body.BlockedUserIDs
}
// ─── PUT /api/v1/blocks/{userId} (handleBlockUser) ──────────────────────────
func TestBlockUser_Success(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4) // user id 2
rr := dmPut(t, router, "/api/v1/blocks/2", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
blocked, err := database.IsBlocked(t.Context(), 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if !blocked {
t.Error("block was not persisted")
}
}
func TestBlockUser_SelfBlockRejected(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmPut(t, router, "/api/v1/blocks/1", alice)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d for a self-block, want 400 (body %q)", rr.Code, rr.Body.String())
}
}
func TestBlockUser_UnknownTarget(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmPut(t, router, "/api/v1/blocks/9999", alice)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d for an unknown target, want 404 (body %q)", rr.Code, rr.Body.String())
}
}
func TestBlockUser_InvalidUserID(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmPut(t, router, "/api/v1/blocks/not-a-number", alice)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d for a non-numeric userId, want 400 (body %q)", rr.Code, rr.Body.String())
}
}
func TestBlockUser_Unauthorized(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
dmCreateToken(t, database, "bob", 4)
rr := dmPut(t, router, "/api/v1/blocks/1", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401 (body %q)", rr.Code, rr.Body.String())
}
}
// ─── DELETE /api/v1/blocks/{userId} (handleUnblockUser) ─────────────────────
func TestUnblockUser_Success(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4)
if rr := dmPut(t, router, "/api/v1/blocks/2", alice); rr.Code != http.StatusOK {
t.Fatalf("setup block failed: status %d", rr.Code)
}
rr := dmDelete(t, router, "/api/v1/blocks/2", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
blocked, err := database.IsBlocked(t.Context(), 1, 2)
if err != nil {
t.Fatalf("IsBlocked: %v", err)
}
if blocked {
t.Error("block survived the unblock request")
}
}
func TestUnblockUser_NotBlockedStillSucceeds(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4)
// Unblocking someone who was never blocked is a no-op, not a 404.
rr := dmDelete(t, router, "/api/v1/blocks/2", alice)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
}
func TestUnblockUser_InvalidUserID(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmDelete(t, router, "/api/v1/blocks/abc", alice)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d for a non-numeric userId, want 400 (body %q)", rr.Code, rr.Body.String())
}
}
func TestUnblockUser_Unauthorized(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
rr := dmDelete(t, router, "/api/v1/blocks/2", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401 (body %q)", rr.Code, rr.Body.String())
}
}
// ─── GET /api/v1/blocks (handleListBlocks) ──────────────────────────────────
func TestListBlocks_EmptyArray(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
rr := dmGet(t, router, "/api/v1/blocks", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
// The service normalizes nil to an empty slice specifically so this
// serializes as [] rather than null.
if got := rr.Body.String(); !jsonContainsEmptyBlockList(got) {
t.Errorf("body = %q, want blocked_user_ids to be []", got)
}
if ids := decodeBlockedIDs(t, rr); len(ids) != 0 {
t.Errorf("blocked_user_ids = %v, want empty", ids)
}
}
func TestListBlocks_ReturnsBlockedIDs(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
alice := dmCreateToken(t, database, "alice", 4)
bob := dmCreateToken(t, database, "bob", 4)
dmCreateToken(t, database, "carol", 4) // user id 3
for _, target := range []string{"2", "3"} {
if rr := dmPut(t, router, "/api/v1/blocks/"+target, alice); rr.Code != http.StatusOK {
t.Fatalf("setup block of %s failed: status %d", target, rr.Code)
}
}
rr := dmGet(t, router, "/api/v1/blocks", alice)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
ids := decodeBlockedIDs(t, rr)
if len(ids) != 2 {
t.Fatalf("blocked_user_ids = %v, want 2 entries", ids)
}
// Blocks are per-user: bob sees none of alice's.
rr = dmGet(t, router, "/api/v1/blocks", bob)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d for bob, want 200", rr.Code)
}
if ids := decodeBlockedIDs(t, rr); len(ids) != 0 {
t.Errorf("bob's blocked_user_ids = %v, want empty", ids)
}
}
func TestListBlocks_Unauthorized(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, &mockBroadcaster{})
rr := dmGet(t, router, "/api/v1/blocks", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401", rr.Code)
}
}
// jsonContainsEmptyBlockList reports whether the payload encodes an empty JSON
// array (not null) for blocked_user_ids.
func jsonContainsEmptyBlockList(body string) bool {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &raw); err != nil {
return false
}
return string(raw["blocked_user_ids"]) == "[]"
}