Files
OwnCord/Server/api/channel_around_test.go
T
Claude 7a4e5dc357 refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)
`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.

The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.

Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.

350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).

The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.

Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.

Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.

Refs RL-13, L-12
2026-08-26 20:23:49 +00:00

400 lines
14 KiB
Go

package api_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/J3vb/OwnCord/Server/db"
)
// ─── GET /api/v1/channels/{id}/messages/around/{messageId} ───────────────────
//
// The around window is what turns a "jump to this message" affordance (search
// hit, pinned entry, reply reference, permalink) into something that works for
// a message outside the client's loaded history. Its contract has three parts
// worth pinning: the same read gate as history, a window actually centred on
// the target, and honest has-more flags at the edges of a channel.
type aroundResponse struct {
Messages []struct {
ID int64 `json:"id"`
Content string `json:"content"`
} `json:"messages"`
HasMoreBefore bool `json:"has_more_before"`
HasMoreAfter bool `json:"has_more_after"`
}
func aroundPath(channelID, messageID int64, query string) string {
p := fmt.Sprintf("/api/v1/channels/%d/messages/around/%d", channelID, messageID)
if query != "" {
p += "?" + query
}
return p
}
// seedAroundChannel creates a channel owned by username and fills it with n
// messages, returning the channel id and the message ids in ascending order.
func seedAroundChannel(t *testing.T, database *db.DB, username string, n int) (int64, []int64) {
t.Helper()
user, err := database.GetUserByUsername(context.Background(), username)
if err != nil {
t.Fatalf("GetUserByUsername(%q): %v", username, err)
}
chID, err := database.CreateChannel(context.Background(), "around-"+username, "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
ids := make([]int64, 0, n)
for i := range n {
id, msgErr := database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil)
if msgErr != nil {
t.Fatalf("CreateMessage %d: %v", i, msgErr)
}
ids = append(ids, id)
}
return chID, ids
}
func decodeAround(t *testing.T, rr *httptest.ResponseRecorder) aroundResponse {
t.Helper()
var resp aroundResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode around response: %v (body: %s)", err, rr.Body.String())
}
return resp
}
func TestMessagesAround_Unauthenticated(t *testing.T) {
router := buildChannelRouter(newChannelTestDB(t))
rr := chGet(t, router, aroundPath(1, 1, ""), "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
func TestMessagesAround_InvalidMessageID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundbadid", 1)
chID, _ := seedAroundChannel(t, database, "aroundbadid", 1)
for _, raw := range []string{"abc", "0", "-3"} {
path := fmt.Sprintf("/api/v1/channels/%d/messages/around/%s", chID, raw)
rr := chGet(t, router, path, token)
if rr.Code != http.StatusBadRequest {
t.Errorf("message id %q: status = %d, want 400; body: %s", raw, rr.Code, rr.Body.String())
}
}
}
func TestMessagesAround_InvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundbadlimit", 1)
chID, ids := seedAroundChannel(t, database, "aroundbadlimit", 3)
rr := chGet(t, router, aroundPath(chID, ids[1], "limit=abc"), token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String())
}
}
func TestMessagesAround_ChannelNotFound(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundnochan", 1)
rr := chGet(t, router, aroundPath(9999, 1, ""), token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestMessagesAround_MessageInAnotherChannel(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundcross", 1)
_, idsA := seedAroundChannel(t, database, "aroundcross", 2)
chB, _ := seedAroundChannel(t, database, "aroundcross", 2)
rr := chGet(t, router, aroundPath(chB, idsA[0], ""), token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestMessagesAround_DeletedMessageIsNotFound(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "arounddeleted", 1)
user, _ := database.GetUserByUsername(context.Background(), "arounddeleted")
chID, ids := seedAroundChannel(t, database, "arounddeleted", 3)
if err := database.DeleteMessage(context.Background(), ids[1], user.ID, false); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
rr := chGet(t, router, aroundPath(chID, ids[1], ""), token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestMessagesAround_CentersTheWindow(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundcenter", 1)
chID, ids := seedAroundChannel(t, database, "aroundcenter", 40)
target := ids[20]
rr := chGet(t, router, aroundPath(chID, target, "limit=10"), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
resp := decodeAround(t, rr)
if len(resp.Messages) != 10 {
t.Fatalf("window size = %d, want 10", len(resp.Messages))
}
// limit 10 → 5 older, the centre, 4 newer.
if resp.Messages[5].ID != target {
t.Errorf("centre at index 5 = %d, want %d", resp.Messages[5].ID, target)
}
for i := 1; i < len(resp.Messages); i++ {
if resp.Messages[i-1].ID >= resp.Messages[i].ID {
t.Fatalf("window is not ascending at index %d: %v then %v",
i, resp.Messages[i-1].ID, resp.Messages[i].ID)
}
}
if !resp.HasMoreBefore || !resp.HasMoreAfter {
t.Errorf("has_more_before = %v, has_more_after = %v; want both true mid-channel",
resp.HasMoreBefore, resp.HasMoreAfter)
}
}
func TestMessagesAround_NearChannelStart(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundstart", 1)
chID, ids := seedAroundChannel(t, database, "aroundstart", 30)
rr := chGet(t, router, aroundPath(chID, ids[0], "limit=10"), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
resp := decodeAround(t, rr)
if len(resp.Messages) == 0 || resp.Messages[0].ID != ids[0] {
t.Fatalf("first entry = %v, want the centre %d at the head", resp.Messages, ids[0])
}
if resp.HasMoreBefore {
t.Error("has_more_before = true at the first message of the channel")
}
if !resp.HasMoreAfter {
t.Error("has_more_after = false with 29 newer messages")
}
// Only the after half is available, so the window is shorter than limit.
if len(resp.Messages) != 5 {
t.Errorf("window size = %d, want 5 (centre + 4 newer)", len(resp.Messages))
}
}
func TestMessagesAround_NearChannelEnd(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundend", 1)
chID, ids := seedAroundChannel(t, database, "aroundend", 30)
last := ids[len(ids)-1]
rr := chGet(t, router, aroundPath(chID, last, "limit=10"), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
resp := decodeAround(t, rr)
if resp.HasMoreAfter {
t.Error("has_more_after = true at the newest message of the channel")
}
if !resp.HasMoreBefore {
t.Error("has_more_before = false with 29 older messages")
}
tail := resp.Messages[len(resp.Messages)-1]
if tail.ID != last {
t.Errorf("last entry = %d, want the centre %d", tail.ID, last)
}
if len(resp.Messages) != 6 {
t.Errorf("window size = %d, want 6 (5 older + centre)", len(resp.Messages))
}
}
func TestMessagesAround_ShortChannelReturnsEverything(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundshort", 1)
chID, ids := seedAroundChannel(t, database, "aroundshort", 3)
rr := chGet(t, router, aroundPath(chID, ids[1], "limit=50"), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
resp := decodeAround(t, rr)
if len(resp.Messages) != 3 {
t.Fatalf("window size = %d, want all 3 messages", len(resp.Messages))
}
if resp.HasMoreBefore || resp.HasMoreAfter {
t.Errorf("has_more_before = %v, has_more_after = %v; want both false",
resp.HasMoreBefore, resp.HasMoreAfter)
}
}
func TestMessagesAround_SkipsDeletedNeighbours(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundtomb", 1)
user, _ := database.GetUserByUsername(context.Background(), "aroundtomb")
chID, ids := seedAroundChannel(t, database, "aroundtomb", 5)
// Soft-delete a neighbour: history omits deleted rows, so the window must
// too — otherwise the client renders a tombstone it never asked for.
if err := database.DeleteMessage(context.Background(), ids[0], user.ID, false); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
rr := chGet(t, router, aroundPath(chID, ids[2], "limit=50"), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
resp := decodeAround(t, rr)
if len(resp.Messages) != 4 {
t.Fatalf("window size = %d, want 4 (5 minus the deleted one)", len(resp.Messages))
}
for _, m := range resp.Messages {
if m.ID == ids[0] {
t.Errorf("deleted message %d present in the window", ids[0])
}
}
}
func TestMessagesAround_DMNonParticipantIsNotFound(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
chTestCreateToken(t, database, "arounddm1", 4)
chTestCreateToken(t, database, "arounddm2", 4)
outsiderToken := chTestCreateToken(t, database, "arounddmout", 4)
user1, _ := database.GetUserByUsername(context.Background(), "arounddm1")
user2, _ := database.GetUserByUsername(context.Background(), "arounddm2")
dmCh, _, err := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, user1.ID, "private", nil)
rr := chGet(t, router, aroundPath(dmCh.ID, msgID, ""), outsiderToken)
if rr.Code != http.StatusNotFound {
t.Errorf("outsider status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestMessagesAround_DMParticipantAllowed(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token1 := chTestCreateToken(t, database, "arounddmok1", 4)
chTestCreateToken(t, database, "arounddmok2", 4)
user1, _ := database.GetUserByUsername(context.Background(), "arounddmok1")
user2, _ := database.GetUserByUsername(context.Background(), "arounddmok2")
dmCh, _, err := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, user1.ID, "private", nil)
rr := chGet(t, router, aroundPath(dmCh.ID, msgID, ""), token1)
if rr.Code != http.StatusOK {
t.Fatalf("participant status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
resp := decodeAround(t, rr)
if len(resp.Messages) != 1 || resp.Messages[0].ID != msgID {
t.Errorf("window = %v, want just the DM message %d", resp.Messages, msgID)
}
}
func TestMessagesAround_NoReadPermissionIsForbidden(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
// Role 4 (Member) with READ_MESSAGES denied on this channel by override.
ownerToken := chTestCreateToken(t, database, "aroundowner", 1)
deniedToken := chTestCreateToken(t, database, "arounddenied", 4)
chID, ids := seedAroundChannel(t, database, "aroundowner", 3)
if _, err := database.ExecContext(context.Background(),
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2147483647)`,
chID,
); err != nil {
t.Fatalf("insert override: %v", err)
}
rr := chGet(t, router, aroundPath(chID, ids[1], ""), deniedToken)
if rr.Code != http.StatusForbidden {
t.Errorf("denied member status = %d, want 403; body: %s", rr.Code, rr.Body.String())
}
// The owner still gets the window — the override, not the endpoint, is the gate.
rr = chGet(t, router, aroundPath(chID, ids[1], ""), ownerToken)
if rr.Code != http.StatusOK {
t.Errorf("owner status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
}
func TestMessagesAround_EnrichesReactionsAndMentions(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "aroundrich", 1)
user, _ := database.GetUserByUsername(context.Background(), "aroundrich")
chID, ids := seedAroundChannel(t, database, "aroundrich", 3)
if err := database.AddReaction(context.Background(), ids[1], user.ID, "👍"); err != nil {
t.Fatalf("AddReaction: %v", err)
}
rr := chGet(t, router, aroundPath(chID, ids[1], ""), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var raw struct {
Messages []struct {
ID int64 `json:"id"`
Reactions []struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Me bool `json:"me"`
} `json:"reactions"`
Attachments []any `json:"attachments"`
Mentions []int64 `json:"mentions"`
} `json:"messages"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode: %v", err)
}
for _, m := range raw.Messages {
if m.Attachments == nil || m.Mentions == nil {
t.Errorf("message %d has null attachments/mentions; the enrichment path was skipped", m.ID)
}
if m.ID != ids[1] {
continue
}
if len(m.Reactions) != 1 || m.Reactions[0].Emoji != "👍" || !m.Reactions[0].Me {
t.Errorf("centre reactions = %v, want one 👍 with me=true", m.Reactions)
}
}
}