From bbf617b831522808ebb652320c78448fe7351404 Mon Sep 17 00:00:00 2001 From: Bretton Date: Wed, 29 Jul 2026 09:53:31 -0700 Subject: [PATCH] =?UTF-8?q?test:=20community=20strangler=20=E2=80=94=20fir?= =?UTF-8?q?st=20pipeline=20contracts,=202:1=20deletion,=20template=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 task 11. community_e2e_test.go (1,787 LOC, 20 sequential subtests) and community_update_e2e_test.go die; their 22 verified behaviors move to named homes: new T1 handler/repo/service tests (update boundary, sort ordering with disambiguating fixtures, write-forward record shape, provisioning handle→DID binding, response envelopes with credential canaries) and two T2 contracts. The ingestion contract self-registers the community's PDS repo so no AppView write ever occurs — proving the consumer's unannounced-repo path through the real container wiring, create/update/delete with Holds. Two strategic findings: sealed-session minting makes authenticated T2 writes impossible today (spec §3.4b amended with the known limitation), and a filed production defect — unverifiable community handles collapse to handle.invalid where a UNIQUE constraint silently drops every subsequent federated community, with pds_url left permanently empty (BridgeTrust denies bridged votes). Two-stream reviewed; 9 fixes. make ci green: 3496 tests, 0 skips. Two of nine serial firehose files retired. Co-Authored-By: Claude Fable 5 --- docs/TEST_ARCHITECTURE.md | 2 + .../api/handlers/community/create_test.go | 159 ++ internal/api/handlers/community/get_test.go | 227 +++ internal/api/handlers/community/list_test.go | 199 ++ .../api/handlers/community/update_test.go | 301 +++ internal/core/communities/harness_test.go | 26 + .../communities/service_provisioning_test.go | 123 ++ .../communities/service_writeforward_test.go | 262 +++ .../db/postgres/community_repo_list_test.go | 177 ++ loop_state.md | 20 +- tests/ci/pending_contracts.txt | 1 - tests/e2e/community_contract_test.go | 474 +++++ tests/integration/community_consumer_test.go | 16 + tests/integration/community_e2e_test.go | 1787 ----------------- .../integration/community_update_e2e_test.go | 381 ---- 15 files changed, 1985 insertions(+), 2170 deletions(-) create mode 100644 internal/api/handlers/community/get_test.go create mode 100644 internal/api/handlers/community/update_test.go create mode 100644 internal/core/communities/harness_test.go create mode 100644 internal/core/communities/service_provisioning_test.go create mode 100644 internal/core/communities/service_writeforward_test.go create mode 100644 internal/db/postgres/community_repo_list_test.go create mode 100644 tests/e2e/community_contract_test.go delete mode 100644 tests/integration/community_e2e_test.go delete mode 100644 tests/integration/community_update_e2e_test.go diff --git a/docs/TEST_ARCHITECTURE.md b/docs/TEST_ARCHITECTURE.md index 1115a24..ec87f2e 100644 --- a/docs/TEST_ARCHITECTURE.md +++ b/docs/TEST_ARCHITECTURE.md @@ -132,6 +132,8 @@ Because the AppView never saw the write, firehose delivery is the *only* way the Client-path through the AppView's XRPC write endpoints, exactly as the mobile app calls them, asserting the *response* and the *synchronous* effects (session issued, record URI returned, synchronously-indexed rows present, blob accepted). These verify what third-party clients experience — including precisely the synchronous-indexing behavior that makes them unsuitable as pipeline proof. Avatars/blob uploads are covered here as steps of the `user` and `community` API contracts (they are blob-path cases of those record types, not record types of their own). +**Known limitation (July 2026, discovered writing the community contract): T2 cannot authenticate a write.** `OAuthAuthMiddleware.RequireAuth` accepts exactly one credential — a *sealed* session token naming a row in the OAuth session store — and the only thing that mints one is `/oauth/callback`, at the end of the browser authorization-code flow against the PDS' own HTML login pages. `social.coves.actor.signup` returns the PDS' `accessJwt`, which is not sealed and is rejected; `/oauth/refresh` requires a sealed token to begin with. The integration tier sidesteps this in-process (`store.SaveSession` + `client.SealSession`), which T2 cannot do without writing to the AppView's own database — the one thing §3.4's rules forbid. So until that changes, an API contract covers **the auth boundary** (every write NSID answers 401 to a session-less client — which also catches a route registered without the middleware, something a handler test structurally cannot see) and **the read surface** (a record indexed through the pipeline is served back by every identifier form a client may use), while authenticated write *behaviour* is proven at T1: handler tests against a mock service, plus write-forward tests that assert the record shape against a real PDS. A hard-gated, test-only session-minting path in the AppView would close the gap and is Phase-5 pre-work, not something a contract may improvise. + **(c) Pipeline-reliability suite — the failure modes CRUD never touches.** The production ingestion path has machinery that steady-state contracts cannot exercise: persisted cursors, reconnect-and-replay, rev-gating (stale events must not resurrect or regress records), duplicate delivery, dead-letter capture, multi-feed consumers. One small suite covers it end-to-end: restart the AppView mid-stream and verify cursor resume; write during a Jetstream outage and verify replay indexes exactly once; deliver a stale rev after a delete and verify no resurrection (`Holds`); poison a record and verify dead-letter capture + consumer health reporting. CI's single self-feed topology differs from prod's multi-feed setup, so this suite also runs one overlapping two-feed configuration to exercise the rev-gating overlap path. diff --git a/internal/api/handlers/community/create_test.go b/internal/api/handlers/community/create_test.go index 709e16e..5a1f1c8 100644 --- a/internal/api/handlers/community/create_test.go +++ b/internal/api/handlers/community/create_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -264,3 +265,161 @@ func TestCreateHandler_RequiresAuth(t *testing.T) { t.Errorf("Expected error AuthRequired, got %s", errResp.Error) } } + +// The create handler's contract with a client: which fields it refuses to take +// from the request, and what a success answers. +// +// The refusals came down a tier from tests/integration/community_e2e_test.go's +// "Create via XRPC endpoint" subtest, which stated them only as a comment +// ("NOTE: Both createdByDid and hostedByDid are derived server-side") while +// asserting neither. They are the community domain's authorship boundary — a +// client that could set createdByDid would create communities in someone else's +// name, and one that could set hostedByDid would claim this instance hosts a +// community for a domain it does not own — so they are worth an actual test. + +// createdBy runs one create request as userDID and returns the service request +// it produced alongside the recorder. +func createdBy(t *testing.T, userDID string, body map[string]any) (communities.CreateCommunityRequest, *httptest.ResponseRecorder) { + t.Helper() + + var forwarded communities.CreateCommunityRequest + handler := NewCreateHandler(&mockCommunityService{ + createFunc: func(_ context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { + forwarded = req + return &communities.Community{ + DID: "did:plc:created", + Handle: "c-" + req.Name + ".coves.social", + RecordURI: "at://did:plc:created/social.coves.community.profile/self", + RecordCID: "bafycreated", + // Seeded so the response assertion can prove the handler serves + // a hand-built envelope rather than the entity — see + // assertRecordWriteEnvelope in update_test.go. A community + // created through the service really does carry these. + PDSPassword: "hunter2", + PDSAccessToken: "access-jwt", + PDSRefreshToken: "refresh-jwt", + }, nil + }, + }, nil) + + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("encoding the request body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.create", bytes.NewReader(encoded)) + req.Header.Set("Content-Type", "application/json") + if userDID != "" { + req = req.WithContext(context.WithValue(req.Context(), middleware.UserDIDKey, userDID)) + } + + w := httptest.NewRecorder() + handler.HandleCreate(w, req) + return forwarded, w +} + +func TestCreateHandler_DerivesTheCreatorFromTheSession(t *testing.T) { + t.Parallel() + + forwarded, w := createdBy(t, "did:plc:author", map[string]any{ + "name": "gaming", + "displayName": "Gaming", + "visibility": "public", + }) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if forwarded.CreatedByDID != "did:plc:author" { + t.Errorf("expected the authenticated DID as the creator, service saw %q", forwarded.CreatedByDID) + } + if forwarded.HostedByDID != "" { + t.Errorf("the handler must leave hostedByDid empty for the service to stamp, forwarded %q", forwarded.HostedByDID) + } +} + +func TestCreateHandler_RefusesClientSuppliedAuthorship(t *testing.T) { + t.Parallel() + + // Refused outright rather than overwritten: silently replacing a supplied + // createdByDid would let a client believe it had created a community on + // another user's behalf, and the 400 is what tells it otherwise. + for _, field := range []string{"createdByDid", "hostedByDid"} { + t.Run(field, func(t *testing.T) { + t.Parallel() + + forwarded, w := createdBy(t, "did:plc:author", map[string]any{ + "name": "gaming", + "visibility": "public", + field: "did:plc:someoneelse", + }) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when a client supplies %s, got %d: %s", field, w.Code, w.Body.String()) + } + if forwarded.Name != "" { + t.Errorf("the handler forwarded the request to the service instead of rejecting it") + } + + var errResp struct { + Error string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { + t.Fatalf("decoding the error response: %v (body %q)", err, w.Body.String()) + } + if errResp.Error != "InvalidRequest" { + t.Errorf("expected error InvalidRequest, got %q", errResp.Error) + } + if !strings.Contains(errResp.Message, field) { + t.Errorf("the message should name the offending field %q, got %q", field, errResp.Message) + } + }) + } +} + +func TestCreateHandler_SuccessResponse(t *testing.T) { + t.Parallel() + + // The four fields social.coves.community.create's lexicon promises, and only + // those four. A client writes the community's first post against this uri + // and addresses it by this did, so a renamed key is a broken client rather + // than a cosmetic change — and an EXTRA key is a credential leak, which is + // why the assertion is on the exact key set. + _, w := createdBy(t, "did:plc:author", map[string]any{ + "name": "gaming", + "visibility": "public", + }) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + assertRecordWriteEnvelope(t, w, map[string]string{ + "uri": "at://did:plc:created/social.coves.community.profile/self", + "cid": "bafycreated", + "did": "did:plc:created", + "handle": "c-gaming.coves.social", + }) +} + +func TestCreateHandler_MalformedBody(t *testing.T) { + t.Parallel() + + handler := NewCreateHandler(&mockCommunityService{ + createFunc: func(context.Context, communities.CreateCommunityRequest) (*communities.Community, error) { + t.Error("the service was called with an undecodable request body") + return nil, nil + }, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.create", + bytes.NewReader([]byte("{not json"))) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:author")) + + w := httptest.NewRecorder() + handler.HandleCreate(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for a malformed body, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/api/handlers/community/get_test.go b/internal/api/handlers/community/get_test.go new file mode 100644 index 0000000..2fb324e --- /dev/null +++ b/internal/api/handlers/community/get_test.go @@ -0,0 +1,227 @@ +package community + +import ( + "Coves/internal/core/communities" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The get handler's contract with a client: which identifier reaches the +// service, what a missing or unknown community answers, and the shape of a +// success. +// +// These came down a tier from tests/integration/community_e2e_test.go's +// "Get via XRPC endpoint" subtest, which needed a real PDS, a real Jetstream +// and a provisioned community to assert that a DID round-trips. The behaviour +// under test is the handler's, so it belongs here where it costs nothing and +// every case can be covered; the pipeline half of it — that a community indexed +// from the firehose is really served by the running AppView — is +// TestCommunityProfileIngestion in tests/e2e. + +// getTestService reuses mockCommunityService's stubs (create_test.go) and +// overrides only the method the get handler calls, so this file adds one method +// rather than a second copy of the thirty-method Service interface. +type getTestService struct { + *mockCommunityService + get func(ctx context.Context, identifier string) (*communities.Community, error) +} + +func (s *getTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { + return s.get(ctx, identifier) +} + +// getTestRepo is the viewer-state repository. The get handler hands it every +// community it is about to serve; listTestRepo (list_test.go) already +// implements the interface as a no-op, which is what an unauthenticated request +// needs — viewer state for a request with no user is nil either way. +func getTestRepo() communities.Repository { return &listTestRepo{} } + +func newGetHandler(t *testing.T, get func(ctx context.Context, identifier string) (*communities.Community, error)) *GetHandler { + t.Helper() + return NewGetHandler(&getTestService{mockCommunityService: &mockCommunityService{}, get: get}, getTestRepo()) +} + +func TestGetHandler_PassesTheIdentifierThrough(t *testing.T) { + t.Parallel() + + // Every identifier form social.coves.community.get accepts. The handler must + // not interpret any of them — resolution is + // communities.ResolveCommunityIdentifier's job, and a handler that + // normalised, lowercased or stripped a prefix on the way past would break + // the scoped form without any service-level test noticing. + for _, identifier := range []string{ + "did:plc:abc123", + "c-gaming.coves.social", + "@c-gaming.coves.social", + "!gaming@coves.social", + } { + t.Run(identifier, func(t *testing.T) { + t.Parallel() + + var seen string + handler := newGetHandler(t, func(_ context.Context, id string) (*communities.Community, error) { + seen = id + return &communities.Community{DID: "did:plc:abc123", Handle: "c-gaming.coves.social", Name: "gaming"}, nil + }) + + w := httptest.NewRecorder() + handler.HandleGet(w, httptest.NewRequest(http.MethodGet, + "/xrpc/social.coves.community.get?community="+url.QueryEscape(identifier), nil)) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, identifier, seen, "the handler altered the identifier before resolving it") + }) + } +} + +func TestGetHandler_MissingCommunityParameter(t *testing.T) { + t.Parallel() + + handler := newGetHandler(t, func(context.Context, string) (*communities.Community, error) { + t.Error("the service was called for a request with no community parameter") + return nil, nil + }) + + w := httptest.NewRecorder() + handler.HandleGet(w, httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.get", nil)) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "InvalidRequest", decodeXRPCError(t, w).Error) +} + +func TestGetHandler_UnknownCommunityIsAnXRPCNotFound(t *testing.T) { + t.Parallel() + + // The error name matters as much as the status. testkit.IsNotFound — which + // every pipeline wait uses to tell "not indexed yet" from "the endpoint is + // broken" — only treats a 404 as not-found when the body is an XRPC error + // envelope, so a bare http.Error here would make the whole T2 tier wait out + // its budget on a community that will never arrive. + handler := newGetHandler(t, func(context.Context, string) (*communities.Community, error) { + return nil, communities.ErrCommunityNotFound + }) + + w := httptest.NewRecorder() + handler.HandleGet(w, httptest.NewRequest(http.MethodGet, + "/xrpc/social.coves.community.get?community=did:plc:missing", nil)) + + require.Equal(t, http.StatusNotFound, w.Code) + body := decodeXRPCError(t, w) + assert.Equal(t, "NotFound", body.Error) + assert.NotEmpty(t, body.Message) +} + +func TestGetHandler_ServiceFailureIsNotA404(t *testing.T) { + t.Parallel() + + // A datastore that is down must not be reported as a community that does not + // exist: a client would cache the absence, and a pipeline wait would treat it + // as "not yet" and hide the outage for the length of its budget. + handler := newGetHandler(t, func(context.Context, string) (*communities.Community, error) { + return nil, errors.New("connection refused") + }) + + w := httptest.NewRecorder() + handler.HandleGet(w, httptest.NewRequest(http.MethodGet, + "/xrpc/social.coves.community.get?community=did:plc:abc123", nil)) + + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestGetHandler_ServesTheDetailedView(t *testing.T) { + t.Parallel() + + created := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + handler := newGetHandler(t, func(context.Context, string) (*communities.Community, error) { + return &communities.Community{ + DID: "did:plc:abc123", + Handle: "c-gaming.coves.social", + Name: "gaming", + DisplayName: "Gaming", + Description: "games and the playing of them", + CreatedByDID: "did:plc:creator", + HostedByDID: "did:web:coves.social", + Visibility: "public", + SubscriberCount: 7, + MemberCount: 3, + PostCount: 11, + CreatedAt: created, + // Credentials are on the entity and must never reach a client. The + // view type is what keeps them off the wire, so a test that serves a + // community carrying them is the one place that can prove it. + PDSPassword: "hunter2", + PDSAccessToken: "access-jwt", + PDSRefreshToken: "refresh-jwt", + }, nil + }) + + w := httptest.NewRecorder() + handler.HandleGet(w, httptest.NewRequest(http.MethodGet, + "/xrpc/social.coves.community.get?community=did:plc:abc123", nil)) + require.Equal(t, http.StatusOK, w.Code) + + var view map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &view)) + + assert.Equal(t, "did:plc:abc123", view["did"]) + assert.Equal(t, "c-gaming.coves.social", view["handle"]) + assert.Equal(t, "gaming", view["name"]) + assert.Equal(t, "Gaming", view["displayName"]) + assert.Equal(t, "games and the playing of them", view["description"]) + assert.Equal(t, "did:plc:creator", view["createdBy"]) + assert.Equal(t, "did:web:coves.social", view["hostedBy"]) + assert.Equal(t, "public", view["visibility"]) + assert.EqualValues(t, 7, view["subscriberCount"]) + assert.EqualValues(t, 3, view["memberCount"]) + assert.EqualValues(t, 11, view["postCount"]) + + // An unauthenticated request has no viewer state, rather than a viewer + // object full of defaults that a client would read as "not subscribed". + assert.NotContains(t, view, "viewer") + + assert.NotContains(t, w.Body.String(), "hunter2", "the community's PDS password reached a client") + assert.NotContains(t, w.Body.String(), "access-jwt", "the community's PDS access token reached a client") + assert.NotContains(t, w.Body.String(), "refresh-jwt", "the community's PDS refresh token reached a client") +} + +// xrpcError is the {error, message} envelope every XRPC failure carries. +type xrpcError struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// decodeXRPCError reads that envelope, failing the test when the body is not +// one — which is the interesting case: a handler that answers with plain text +// is invisible to clients (and to testkit) that match on the error name. +func decodeXRPCError(t *testing.T, w *httptest.ResponseRecorder) xrpcError { + t.Helper() + var body xrpcError + require.NoErrorf(t, json.Unmarshal(w.Body.Bytes(), &body), + "expected an XRPC error envelope, got %q", w.Body.String()) + require.NotEmptyf(t, body.Error, "expected an XRPC error name, got %q", w.Body.String()) + return body +} + +func TestGetHandler_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handler := newGetHandler(t, func(context.Context, string) (*communities.Community, error) { + t.Error("the service was called for a POST") + return nil, nil + }) + + w := httptest.NewRecorder() + handler.HandleGet(w, httptest.NewRequest(http.MethodPost, + "/xrpc/social.coves.community.get?community=did:plc:abc123", nil)) + + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} diff --git a/internal/api/handlers/community/list_test.go b/internal/api/handlers/community/list_test.go index 10cc3a6..7776ca9 100644 --- a/internal/api/handlers/community/list_test.go +++ b/internal/api/handlers/community/list_test.go @@ -7,6 +7,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" + "strings" "testing" "time" @@ -474,3 +476,200 @@ func TestListHandler_MethodNotAllowed(t *testing.T) { t.Errorf("Expected status 405, got %d", w.Code) } } + +// The list handler's query surface: which sorts and filters it accepts, what it +// refuses, and the response envelope. +// +// These came down a tier from tests/integration/community_e2e_test.go, whose +// "List with sort=…" subtests spent a real PDS, a real Jetstream and a +// provisioned community each to assert that a sort parameter produced HTTP 200 +// (docs/TEST_ARCHITECTURE.md §3.4 rule 3: behavioural breadth is a T1/T0 +// concern). Down here every case is covered instead of four of them, the +// parameter's onward journey into the service request is visible, and the +// ORDERING those sorts actually produce is asserted where it is implemented — +// internal/db/postgres's community repository, against real SQL. + +// listedBy runs one list request and returns the service request it produced +// alongside the recorder, so a test can assert on both halves of the handler's +// job: what it forwarded, and what it answered. +func listedBy(t *testing.T, query string) (communities.ListCommunitiesRequest, *httptest.ResponseRecorder) { + t.Helper() + + var forwarded communities.ListCommunitiesRequest + handler := NewListHandler(&listTestService{ + listFunc: func(_ context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { + forwarded = req + return []*communities.Community{}, nil + }, + }, &listTestRepo{}) + + w := httptest.NewRecorder() + handler.HandleList(w, httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.list?"+query, nil)) + return forwarded, w +} + +func TestListHandler_SortParameter(t *testing.T) { + t.Parallel() + + for _, sort := range []string{"popular", "active", "new", "alphabetical"} { + t.Run(sort, func(t *testing.T) { + t.Parallel() + forwarded, w := listedBy(t, "sort="+sort) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for sort=%s, got %d: %s", sort, w.Code, w.Body.String()) + } + if forwarded.Sort != sort { + t.Errorf("expected the handler to forward sort=%q, forwarded %q", sort, forwarded.Sort) + } + }) + } +} + +func TestListHandler_SortDefaultsToPopular(t *testing.T) { + t.Parallel() + + // The default is applied in the handler rather than left empty for the + // repository to interpret, so an omitted sort and sort=popular must produce + // the identical service request. + forwarded, w := listedBy(t, "limit=10") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if forwarded.Sort != "popular" { + t.Errorf("expected an absent sort to default to popular, got %q", forwarded.Sort) + } +} + +func TestListHandler_InvalidSortIsRejected(t *testing.T) { + t.Parallel() + + // KNOWN INCONSISTENCY, asserted as it is rather than as it should be: this + // rejection is http.Error, so the body is plain text and carries no XRPC + // error name — unlike the limit and cursor rejections a few lines above it + // in the same handler. A client matching on the error name sees nothing to + // match. Changing it is a client-visible API change and belongs in its own + // commit; pinning it here means that commit cannot happen by accident. + forwarded, w := listedBy(t, "sort=trending") + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an unknown sort, got %d: %s", w.Code, w.Body.String()) + } + if forwarded.Sort != "" { + t.Error("the handler queried the service with an invalid sort instead of rejecting the request") + } + if body := w.Body.String(); !strings.Contains(body, "popular, active, new, or alphabetical") { + t.Errorf("the rejection should name the accepted sorts, got %q", body) + } +} + +func TestListHandler_VisibilityFilter(t *testing.T) { + t.Parallel() + + for _, visibility := range []string{"public", "unlisted", "private"} { + t.Run(visibility, func(t *testing.T) { + t.Parallel() + forwarded, w := listedBy(t, "visibility="+visibility) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for visibility=%s, got %d: %s", visibility, w.Code, w.Body.String()) + } + if forwarded.Visibility != visibility { + t.Errorf("expected the handler to forward visibility=%q, forwarded %q", visibility, forwarded.Visibility) + } + }) + } + + t.Run("absent", func(t *testing.T) { + t.Parallel() + // No default: an empty visibility is what tells the repository not to + // filter at all, so substituting "public" here would silently hide + // unlisted communities from every unfiltered listing. + forwarded, _ := listedBy(t, "limit=10") + if forwarded.Visibility != "" { + t.Errorf("expected an absent visibility to stay empty, got %q", forwarded.Visibility) + } + }) + + t.Run("invalid", func(t *testing.T) { + t.Parallel() + forwarded, w := listedBy(t, "visibility=secret") + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an unknown visibility, got %d: %s", w.Code, w.Body.String()) + } + if forwarded.Visibility != "" { + t.Error("the handler queried the service with an invalid visibility instead of rejecting the request") + } + }) +} + +func TestListHandler_ResponseEnvelope(t *testing.T) { + t.Parallel() + + // The cursor is the pagination contract: offset-based, and present only when + // the page was full. A cursor returned on a short page would make a client + // page forever. + tests := []struct { + name string + query string + returned int + expectedCursor string + }{ + {name: "a short page ends the listing", query: "limit=10", returned: 3, expectedCursor: ""}, + {name: "a full page offers the next offset", query: "limit=3", returned: 3, expectedCursor: "3"}, + {name: "a full page continues from the cursor", query: "limit=3&cursor=6", returned: 3, expectedCursor: "9"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + results := make([]*communities.Community, tc.returned) + for i := range results { + results[i] = &communities.Community{ + DID: "did:plc:listed" + strconv.Itoa(i), + Handle: "c-listed" + strconv.Itoa(i) + ".coves.social", + Name: "listed" + strconv.Itoa(i), + Visibility: "public", + PDSPassword: "hunter2", + PDSAccessToken: "access-jwt", + PDSRefreshToken: "refresh-jwt", + } + } + + handler := NewListHandler(&listTestService{ + listFunc: func(_ context.Context, _ communities.ListCommunitiesRequest) ([]*communities.Community, error) { + return results, nil + }, + }, &listTestRepo{}) + + w := httptest.NewRecorder() + handler.HandleList(w, httptest.NewRequest(http.MethodGet, + "/xrpc/social.coves.community.list?"+tc.query, nil)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Communities []communities.CommunityView `json:"communities"` + Cursor *string `json:"cursor"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("decoding the list response: %v (body %q)", err, w.Body.String()) + } + if len(response.Communities) != tc.returned { + t.Errorf("expected %d communities, got %d", tc.returned, len(response.Communities)) + } + // Always present, even when empty: a client that reads a missing key + // as "no more pages" and an empty string as "page 0" would loop. + if response.Cursor == nil { + t.Fatalf("the response omitted the cursor key entirely: %s", w.Body.String()) + } + if *response.Cursor != tc.expectedCursor { + t.Errorf("expected cursor %q, got %q", tc.expectedCursor, *response.Cursor) + } + if strings.Contains(w.Body.String(), "hunter2") || + strings.Contains(w.Body.String(), "-jwt") { + t.Error("a community's PDS credentials reached a client through the list view") + } + }) + } +} diff --git a/internal/api/handlers/community/update_test.go b/internal/api/handlers/community/update_test.go new file mode 100644 index 0000000..259cc16 --- /dev/null +++ b/internal/api/handlers/community/update_test.go @@ -0,0 +1,301 @@ +package community + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/communities" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The update handler's contract with a client: which fields reach the service, +// who the service is told is making the change, and what a success answers. +// +// This was the last community write endpoint with no handler test at all. Its +// only coverage was tests/integration/community_e2e_test.go's "Update via XRPC +// endpoint" subtest — a real PDS, a real Jetstream and a provisioned community +// to establish that three fields round-tripped — so deleting that file left +// HandleUpdate uninvoked by anything. The service half (write-forward to the +// community's repo, creator-only authorization) is +// TestCommunityService_UpdateWithRealPDS; the pipeline half (a directly-written +// update reaching a serving endpoint) is TestCommunityProfileIngestion in +// tests/e2e. What is missing without this file is the HTTP boundary between +// them, which is where a renamed JSON key or a dropped optional field lives. + +// updateTestService reuses mockCommunityService's stubs (create_test.go) and +// overrides only the method the update handler calls. +type updateTestService struct { + *mockCommunityService + update func(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) +} + +func (s *updateTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { + return s.update(ctx, req) +} + +// updatedBy runs one update request as userDID (empty for an unauthenticated +// caller) and returns the service request it produced alongside the recorder. +func updatedBy(t *testing.T, userDID string, body any) (communities.UpdateCommunityRequest, *httptest.ResponseRecorder) { + t.Helper() + + var forwarded communities.UpdateCommunityRequest + handler := NewUpdateHandler(&updateTestService{ + mockCommunityService: &mockCommunityService{}, + update: func(_ context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { + forwarded = req + return &communities.Community{ + DID: req.CommunityDID, + Handle: "c-gaming.coves.social", + RecordURI: "at://" + req.CommunityDID + "/social.coves.community.profile/self", + RecordCID: "bafyupdated", + // Seeded so the response assertion can prove the handler serves a + // hand-built envelope rather than the entity: a future refactor + // that encodes the community directly would put these on the wire. + PDSPassword: "hunter2", + PDSAccessToken: "access-jwt", + PDSRefreshToken: "refresh-jwt", + }, nil + }, + }) + + var encoded []byte + switch payload := body.(type) { + case string: + encoded = []byte(payload) + default: + var err error + encoded, err = json.Marshal(payload) + require.NoError(t, err, "encoding the request body") + } + + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.update", bytes.NewReader(encoded)) + req.Header.Set("Content-Type", "application/json") + if userDID != "" { + req = req.WithContext(context.WithValue(req.Context(), middleware.UserDIDKey, userDID)) + } + + w := httptest.NewRecorder() + handler.HandleUpdate(w, req) + return forwarded, w +} + +func TestUpdateHandler_ForwardsEveryChangedField(t *testing.T) { + t.Parallel() + + // Every optional field is a pointer, because nil and "" mean different + // things to the service: nil keeps the stored value, a pointer to "" clears + // it. A handler that flattened them would silently erase a description on + // every update that did not mention one. + forwarded, w := updatedBy(t, "did:plc:author", map[string]any{ + "communityDid": "did:plc:community", + "displayName": "Gaming Renamed", + "description": "now with more games", + "visibility": "unlisted", + "allowExternalDiscovery": false, + "moderationType": "sortition", + "contentWarnings": []string{"nsfw"}, + }) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, "did:plc:community", forwarded.CommunityDID) + require.NotNil(t, forwarded.DisplayName) + assert.Equal(t, "Gaming Renamed", *forwarded.DisplayName) + require.NotNil(t, forwarded.Description) + assert.Equal(t, "now with more games", *forwarded.Description) + require.NotNil(t, forwarded.Visibility) + assert.Equal(t, "unlisted", *forwarded.Visibility) + require.NotNil(t, forwarded.AllowExternalDiscovery) + assert.False(t, *forwarded.AllowExternalDiscovery, + "a false allowExternalDiscovery must survive as false, not as absent") + require.NotNil(t, forwarded.ModerationType) + assert.Equal(t, "sortition", *forwarded.ModerationType) + assert.Equal(t, []string{"nsfw"}, forwarded.ContentWarnings) +} + +func TestUpdateHandler_OmittedFieldsStayNil(t *testing.T) { + t.Parallel() + + // The other half of the pointer contract: a request that mentions only the + // display name must leave everything else nil, so the service keeps the + // stored values instead of overwriting them with zeroes. + forwarded, w := updatedBy(t, "did:plc:author", map[string]any{ + "communityDid": "did:plc:community", + "displayName": "Only This", + }) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + require.NotNil(t, forwarded.DisplayName) + assert.Equal(t, "Only This", *forwarded.DisplayName) + assert.Nil(t, forwarded.Description) + assert.Nil(t, forwarded.Visibility) + assert.Nil(t, forwarded.AllowExternalDiscovery) + assert.Nil(t, forwarded.ModerationType) + assert.Nil(t, forwarded.ContentWarnings) +} + +func TestUpdateHandler_DerivesTheUpdaterFromTheSession(t *testing.T) { + t.Parallel() + + // DEFINED BEHAVIOUR, and it is not the create handler's: a client-supplied + // updatedByDid is OVERWRITTEN with the session's DID rather than refused + // with a 400 (update.go: `req.UpdatedByDID = userDID`, unconditionally). + // Either policy is defensible — what matters is that the value the service + // authorizes against can only come from the session, which is what this + // pins. The asymmetry with create's explicit rejection is worth knowing + // about before anyone "makes them consistent" in one direction or the other. + forwarded, w := updatedBy(t, "did:plc:realauthor", map[string]any{ + "communityDid": "did:plc:community", + "updatedByDid": "did:plc:someoneelse", + "displayName": "Renamed", + }) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, "did:plc:realauthor", forwarded.UpdatedByDID, + "the service must authorize against the session's DID, never the body's") +} + +func TestUpdateHandler_RequiresAuth(t *testing.T) { + t.Parallel() + + forwarded, w := updatedBy(t, "", map[string]any{ + "communityDid": "did:plc:community", + "displayName": "Renamed", + }) + + require.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "AuthRequired", decodeXRPCError(t, w).Error) + assert.Empty(t, forwarded.CommunityDID, "the handler called the service for an unauthenticated request") +} + +func TestUpdateHandler_RejectsIncompleteRequests(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + body any + }{ + {name: "no communityDid", body: map[string]any{"displayName": "Renamed"}}, + {name: "empty communityDid", body: map[string]any{"communityDid": "", "displayName": "Renamed"}}, + {name: "malformed body", body: "{not json"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + forwarded, w := updatedBy(t, "did:plc:author", tc.body) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, "InvalidRequest", decodeXRPCError(t, w).Error) + assert.Empty(t, forwarded.CommunityDID, "the handler forwarded an invalid request to the service") + }) + } +} + +func TestUpdateHandler_ServiceErrors(t *testing.T) { + t.Parallel() + + // The two that a client can actually provoke, mapped by errors.go: a + // community that is gone, and one the caller does not own. A 403 arriving as + // a 500 would tell a client to retry an update it will never be allowed to + // make. + for _, tc := range []struct { + name string + err error + status int + xrpcName string + }{ + {name: "unknown community", err: communities.ErrCommunityNotFound, status: http.StatusNotFound, xrpcName: "NotFound"}, + {name: "not the creator", err: communities.ErrUnauthorized, status: http.StatusForbidden, xrpcName: "Forbidden"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handler := NewUpdateHandler(&updateTestService{ + mockCommunityService: &mockCommunityService{}, + update: func(context.Context, communities.UpdateCommunityRequest) (*communities.Community, error) { + return nil, tc.err + }, + }) + + body, err := json.Marshal(map[string]any{"communityDid": "did:plc:community", "displayName": "Renamed"}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.update", bytes.NewReader(body)) + req = req.WithContext(context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:author")) + + w := httptest.NewRecorder() + handler.HandleUpdate(w, req) + + require.Equal(t, tc.status, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, tc.xrpcName, decodeXRPCError(t, w).Error) + }) + } +} + +func TestUpdateHandler_SuccessResponse(t *testing.T) { + t.Parallel() + + _, w := updatedBy(t, "did:plc:author", map[string]any{ + "communityDid": "did:plc:community", + "displayName": "Renamed", + }) + require.Equal(t, http.StatusOK, w.Code) + + assertRecordWriteEnvelope(t, w, map[string]string{ + "uri": "at://did:plc:community/social.coves.community.profile/self", + "cid": "bafyupdated", + "did": "did:plc:community", + "handle": "c-gaming.coves.social", + }) +} + +func TestUpdateHandler_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handler := NewUpdateHandler(&updateTestService{ + mockCommunityService: &mockCommunityService{}, + update: func(context.Context, communities.UpdateCommunityRequest) (*communities.Community, error) { + t.Error("the service was called for a GET") + return nil, nil + }, + }) + + w := httptest.NewRecorder() + handler.HandleUpdate(w, httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.update", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} + +// assertRecordWriteEnvelope checks a write endpoint's success body against the +// EXACT key set its lexicon promises. +// +// Exact, not a subset: the community entity these handlers build their response +// from carries the community's PDS password and both of its tokens, so the +// interesting failure is not a missing key but an extra one. A handler that +// grew a field — or was refactored to encode the entity directly — would still +// satisfy every "the uri is right" assertion while putting credentials on the +// wire. The fixtures seed those three secrets precisely so this can catch it. +func assertRecordWriteEnvelope(t *testing.T, w *httptest.ResponseRecorder, expected map[string]string) { + t.Helper() + + var response map[string]any + require.NoErrorf(t, json.Unmarshal(w.Body.Bytes(), &response), + "decoding the response: %q", w.Body.String()) + + keys := make([]string, 0, len(response)) + for key := range response { + keys = append(keys, key) + } + assert.ElementsMatch(t, []string{"uri", "cid", "did", "handle"}, keys, + "the response key set is the lexicon's output shape, exactly: %s", w.Body.String()) + + for key, want := range expected { + assert.Equalf(t, want, response[key], "response field %q", key) + } + + for _, secret := range []string{"hunter2", "access-jwt", "refresh-jwt"} { + assert.NotContainsf(t, w.Body.String(), secret, + "a community PDS credential (%s) reached a client", secret) + } +} diff --git a/internal/core/communities/harness_test.go b/internal/core/communities/harness_test.go new file mode 100644 index 0000000..409e589 --- /dev/null +++ b/internal/core/communities/harness_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package communities_test + +import ( + "os" + "testing" + + "Coves/tests/testkit" +) + +// TestMain sets the infrastructure floor for this package's integration build. +// +// It lives in a tagged file because a TestMain applies to the whole test binary: +// the untagged unit build of this package needs nothing out of process, and must +// not be made to probe Postgres and a PDS before it can run. A future unit test +// file here must therefore NOT declare its own TestMain — with -tags integration +// both files compile into one binary, and two TestMains do not. +// +// The tests are in package communities_test (external) rather than in +// communities, because they exercise the service against the real repository in +// internal/db/postgres, which imports communities. In-package that is an import +// cycle; from outside it is an ordinary dependency. +func TestMain(m *testing.M) { + os.Exit(testkit.Main(m, testkit.RequirePostgres, testkit.RequirePDS)) +} diff --git a/internal/core/communities/service_provisioning_test.go b/internal/core/communities/service_provisioning_test.go new file mode 100644 index 0000000..584f0ff --- /dev/null +++ b/internal/core/communities/service_provisioning_test.go @@ -0,0 +1,123 @@ +//go:build integration + +package communities_test + +import ( + "Coves/internal/atproto/pds" + "Coves/internal/core/communities" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + "context" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// What creating a community actually produces on the PDS. +// +// tests/integration/community_service_integration_test.go already covers the +// service's own view of provisioning — the returned DID, handle, record URI and +// the credentials landing encrypted in Postgres. What it does NOT check, and +// what tests/integration/community_e2e_test.go's deleted queryPDSAccount step +// did, is the binding on the OTHER side: that the handle the service reports is +// a handle the PDS will actually resolve, to this community's DID. +// +// That is not a formality. The service derives the handle by string +// construction (pds_provisioning.go: "c-%s.%s") and reports it from its own +// request rather than from the PDS' answer, so a PDS that normalised, rejected +// or reassigned it would leave the AppView holding a handle nothing resolves — +// and every client that addresses a community by handle would 404 while the row +// looked perfectly healthy. The AppView's own community consumer depends on the +// same binding when it resolves a federated community's handle from its DID +// document. + +const ( + // The instance identity these tests provision under. It matches the + // AppView's default (internal/config: INSTANCE_DID defaults to + // did:web:coves.social, and the domain is derived from it), and the domain + // must be one of the PDS' PDS_SERVICE_HANDLE_DOMAINS or account creation is + // refused. + instanceDID = "did:web:coves.social" + instanceDomain = "coves.social" +) + +// newCommunityService builds the service under test over a fresh database clone +// and the test PDS. +// +// It is wired the way cmd/server wires it, with one substitution: the PDS +// client factory is password auth rather than OAuth/DPoP, so a test can hold a +// user session without an authorization-code flow. Everything on the community +// side — the provisioner, the credential storage, the write-forwards — is the +// production path. +func newCommunityService(t *testing.T) (communities.Service, communities.Repository, *testkit.PDS) { + t.Helper() + + repo := postgres.NewCommunityRepository(testkit.DB(t)) + pdsServer := testkit.NewPDS(t) + service := communities.NewCommunityServiceWithPDSFactory( + repo, + pdsServer.URL(), + instanceDID, + instanceDomain, + communities.NewPDSAccountProvisioner(instanceDomain, pdsServer.URL()), + testkit.PasswordAuthFactory(pds.NewFromAccessToken), + nil, + ) + return service, repo, pdsServer +} + +func TestService_CreateProvisionsAResolvableAccount(t *testing.T) { + t.Parallel() + + service, _, pdsServer := newCommunityService(t) + ctx := context.Background() + + // The name is short enough that "c-" plus it stays inside the PDS' 18 + // character local-label cap — the same budget every generated handle lives + // in, and the reason UniqueIDWithPrefix is the only generator allowed. + name := testkit.UniqueIDWithPrefix(t, "p") + require.LessOrEqualf(t, len("c-"+name), testkit.MaxIDLength, + "the generated community name %q makes a handle label the PDS will refuse", name) + + community, err := service.CreateCommunity(ctx, communities.CreateCommunityRequest{ + Name: name, + DisplayName: "Provisioning", + Description: "a community with a real PDS account", + Visibility: "public", + CreatedByDID: "did:plc:provisioningtest", + AllowExternalDiscovery: true, + }) + require.NoError(t, err) + + expectedHandle := "c-" + name + "." + instanceDomain + require.Equal(t, expectedHandle, community.Handle, + "the provisioner's handle convention is c-.") + require.True(t, strings.HasPrefix(community.DID, "did:plc:"), + "the PDS mints a did:plc for the community, got %q", community.DID) + + // THE BINDING. Asked of the PDS, not of the service: this is the only + // assertion in the suite that the handle the AppView stores is one the + // network will resolve back to this community. + var resolved struct { + DID string `json:"did"` + } + require.NoError(t, pdsServer.Anon.Query(ctx, "com.atproto.identity.resolveHandle", + url.Values{"handle": {expectedHandle}}, &resolved)) + assert.Equal(t, community.DID, resolved.DID, + "the PDS resolves %s to a different DID than the community the service reported", expectedHandle) + + // And the community owns its own repository: the profile record is in the + // community's repo at the canonical rkey, not in the instance's. + assert.Equal(t, "at://"+community.DID+"/social.coves.community.profile/self", community.RecordURI) + assert.Equal(t, community.DID, community.OwnerDID, "V2: a community owns itself") + + record := pdsServer.Login(t, expectedHandle, community.PDSPassword). + GetRecord(t, "social.coves.community.profile", "self") + assert.Equal(t, name, record.Value["name"]) + assert.Equal(t, instanceDID, record.Value["hostedBy"], + "hostedBy is stamped from the instance configuration, never from the request") + assert.Equal(t, "did:plc:provisioningtest", record.Value["createdBy"]) +} diff --git a/internal/core/communities/service_writeforward_test.go b/internal/core/communities/service_writeforward_test.go new file mode 100644 index 0000000..cf32d96 --- /dev/null +++ b/internal/core/communities/service_writeforward_test.go @@ -0,0 +1,262 @@ +//go:build integration + +package communities_test + +import ( + "Coves/internal/core/communities" + "Coves/tests/testkit" + "context" + "net/url" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// What subscribing and blocking actually write to a user's repo. +// +// These are the halves of tests/integration/community_e2e_test.go's +// Subscribe/Unsubscribe/Block/Unblock subtests that nothing else covered. Those +// subtests each did three things at once: called the XRPC endpoint, fetched the +// resulting record from the PDS, and hand-fed a synthetic event to a consumer. +// Two of the three now have better homes — handler behaviour is +// internal/api/handlers/community's subscribe_test.go and block_test.go, and +// consumer behaviour is tests/integration's subscription_indexing_test.go and +// community_blocking_test.go, both of which cover more cases than the deleted +// file did. What was left, and is here, is the write-forward itself: the record +// the service puts in the user's repo. +// +// # WHY THE COLLECTION NAME IS THE POINT +// +// The deleted file shouted about this in capitals at four separate call sites +// ("CRITICAL: Use correct collection name (record type, not XRPC endpoint)"), +// which is a comment doing a test's job. social.coves.community.subscribe is a +// PROCEDURE — an HTTP endpoint — and social.coves.community.subscription is the +// RECORD TYPE it creates; writing the procedure NSID as a collection produces a +// record no consumer is subscribed to, so the write succeeds, the client sees +// 200, and the subscription silently never indexes. Nothing downstream can +// detect it: the consumer tests feed themselves correctly-named events. Only a +// test that reads the repo back can, which is this one. + +// writeForwardFixture is the service under test, wired the way production wires +// it except for the PDS client factory: password auth instead of OAuth/DPoP, so +// a test can hold a session without an authorization-code flow. +type writeForwardFixture struct { + service communities.Service + repo communities.Repository + user *testkit.Account + session *oauth.ClientSessionData + community *communities.Community +} + +func newWriteForwardFixture(t *testing.T) *writeForwardFixture { + t.Helper() + + service, repo, pdsServer := newCommunityService(t) + user := pdsServer.CreateAccount(t, testkit.WithHandlePrefix("wf")) + did, err := syntax.ParseDID(user.DID) + require.NoError(t, err) + + // The community is seeded straight into the index rather than provisioned + // through the service: subscribing and blocking read the community row and + // write to the USER's repo, so the community's own PDS account is not part + // of what is under test here, and provisioning one would add an account + // creation to every case. + name := testkit.UniqueIDWithPrefix(t, "wfc") + community, err := repo.Create(context.Background(), &communities.Community{ + DID: "did:plc:" + name, + Handle: "c-" + name + "." + instanceDomain, + Name: name, + OwnerDID: "did:plc:" + name, + CreatedByDID: user.DID, + HostedByDID: instanceDID, + Visibility: "public", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + require.NoError(t, err) + + return &writeForwardFixture{ + service: service, + repo: repo, + user: user, + session: &oauth.ClientSessionData{ + AccountDID: did, + SessionID: "write-forward-test", + HostURL: pdsServer.URL(), + AccessToken: user.AccessToken, + }, + community: community, + } +} + +// getRecordErr asks the PDS for a record and returns only the error, so a test +// can assert a record's ABSENCE — Account.GetRecord fails the test on a missing +// record, which is the right default and the wrong tool here. +func getRecordErr(ctx context.Context, account *testkit.Account, collection, rkey string) error { + return account.XRPC().Query(ctx, "com.atproto.repo.getRecord", url.Values{ + "repo": {account.DID}, + "collection": {collection}, + "rkey": {rkey}, + }, nil) +} + +// rkeyOf returns the record key an AT-URI ends with. +func rkeyOf(t *testing.T, uri string) string { + t.Helper() + parsed, err := syntax.ParseATURI(uri) + require.NoErrorf(t, err, "the service returned an unparseable record URI %q", uri) + rkey := parsed.RecordKey().String() + require.NotEmptyf(t, rkey, "the record URI %q has no record key", uri) + return rkey +} + +func TestService_SubscribeWritesASubscriptionRecord(t *testing.T) { + t.Parallel() + + f := newWriteForwardFixture(t) + ctx := context.Background() + + subscription, err := f.service.SubscribeToCommunity(ctx, f.session, f.community.DID, 5) + require.NoError(t, err) + require.Equal(t, f.community.DID, subscription.CommunityDID) + require.Equal(t, f.user.DID, subscription.UserDID) + require.Equal(t, 5, subscription.ContentVisibility) + + // The record lives in the SUBSCRIBER's repo, not the community's: that is + // what makes a subscription portable with the user rather than a row the + // community owns. + assert.Equal(t, + "at://"+f.user.DID+"/social.coves.community.subscription/"+rkeyOf(t, subscription.RecordURI), + subscription.RecordURI) + + record := f.user.GetRecord(t, "social.coves.community.subscription", rkeyOf(t, subscription.RecordURI)) + assert.Equal(t, "social.coves.community.subscription", record.Value["$type"]) + assert.Equal(t, f.community.DID, record.Value["subject"], + "atProto convention: the community is referenced by a subject field") + // JSON numbers decode as float64, which is also how the consumer receives + // them from Jetstream. + assert.EqualValues(t, 5, record.Value["contentVisibility"]) + assert.NotEmpty(t, record.Value["createdAt"]) +} + +func TestService_SubscribeClampsContentVisibility(t *testing.T) { + t.Parallel() + + // The service's clamp is not the consumer's. Out-of-range here means "the + // client sent nonsense, use the default", so 0 and 9 both become 3; + // extractContentVisibility in the community consumer clamps INTO the range + // instead, so a 9 that reaches it from the firehose becomes 5. Both are + // defensible and they disagree, which is worth having written down: a + // client that sends 9 gets 3 in its record, and a federated record carrying + // 9 indexes as 5. + for _, tc := range []struct { + name string + requested int + stored int + }{ + {name: "zero means unset", requested: 0, stored: 3}, + {name: "negative", requested: -1, stored: 3}, + {name: "above the range", requested: 9, stored: 3}, + {name: "in range", requested: 1, stored: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + f := newWriteForwardFixture(t) + subscription, err := f.service.SubscribeToCommunity( + context.Background(), f.session, f.community.DID, tc.requested) + require.NoError(t, err) + + assert.Equal(t, tc.stored, subscription.ContentVisibility) + record := f.user.GetRecord(t, "social.coves.community.subscription", rkeyOf(t, subscription.RecordURI)) + assert.EqualValues(t, tc.stored, record.Value["contentVisibility"], + "the clamped value must be what lands in the repo, not only what is returned") + }) + } +} + +func TestService_UnsubscribeDeletesTheSubscriptionRecord(t *testing.T) { + t.Parallel() + + f := newWriteForwardFixture(t) + ctx := context.Background() + + subscription, err := f.service.SubscribeToCommunity(ctx, f.session, f.community.DID, 3) + require.NoError(t, err) + rkey := rkeyOf(t, subscription.RecordURI) + + // Unsubscribe finds the record key through the AppView's index, so the + // subscription has to be indexed first — which the firehose would have done + // by now in production, and which this test does directly because the + // consumer is not what is under test. + _, err = f.repo.SubscribeWithCount(ctx, subscription) + require.NoError(t, err) + + require.NoError(t, f.service.UnsubscribeFromCommunity(ctx, f.session, f.community.DID)) + + assert.True(t, testkit.IsNotFound(getRecordErr(ctx, f.user, "social.coves.community.subscription", rkey)), + "the subscription record is still in the user's repo after unsubscribing") +} + +func TestService_BlockWritesABlockRecord(t *testing.T) { + t.Parallel() + + f := newWriteForwardFixture(t) + ctx := context.Background() + + block, err := f.service.BlockCommunity(ctx, f.session, f.community.DID) + require.NoError(t, err) + require.Equal(t, f.community.DID, block.CommunityDID) + require.Equal(t, f.user.DID, block.UserDID) + + rkey := rkeyOf(t, block.RecordURI) + assert.Equal(t, "at://"+f.user.DID+"/social.coves.community.block/"+rkey, block.RecordURI) + + record := f.user.GetRecord(t, "social.coves.community.block", rkey) + assert.Equal(t, "social.coves.community.block", record.Value["$type"]) + assert.Equal(t, f.community.DID, record.Value["subject"]) + assert.NotEmpty(t, record.Value["createdAt"]) +} + +func TestService_UnblockDeletesTheBlockRecord(t *testing.T) { + t.Parallel() + + f := newWriteForwardFixture(t) + ctx := context.Background() + + block, err := f.service.BlockCommunity(ctx, f.session, f.community.DID) + require.NoError(t, err) + rkey := rkeyOf(t, block.RecordURI) + + // As with unsubscribe, the record key comes from the index. + _, err = f.repo.BlockCommunity(ctx, block) + require.NoError(t, err) + + require.NoError(t, f.service.UnblockCommunity(ctx, f.session, f.community.DID)) + assert.True(t, testkit.IsNotFound(getRecordErr(ctx, f.user, "social.coves.community.block", rkey)), + "the block record is still in the user's repo after unblocking") +} + +func TestService_WriteForwardResolvesEveryIdentifierForm(t *testing.T) { + t.Parallel() + + // Subscribing by handle must reach the same repo write as subscribing by + // DID. The resolution itself is covered exhaustively at the service level + // elsewhere; what this adds is that the write path uses the RESOLVED did as + // the record's subject, rather than storing whatever string the client sent + // — a subject carrying a handle would be a record no consumer can join to a + // community. + f := newWriteForwardFixture(t) + ctx := context.Background() + + subscription, err := f.service.SubscribeToCommunity(ctx, f.session, f.community.Handle, 3) + require.NoError(t, err) + + record := f.user.GetRecord(t, "social.coves.community.subscription", rkeyOf(t, subscription.RecordURI)) + assert.Equal(t, f.community.DID, record.Value["subject"], + "a subscription created by handle must still record the community's DID") +} diff --git a/internal/db/postgres/community_repo_list_test.go b/internal/db/postgres/community_repo_list_test.go new file mode 100644 index 0000000..36963e7 --- /dev/null +++ b/internal/db/postgres/community_repo_list_test.go @@ -0,0 +1,177 @@ +//go:build integration + +package postgres + +import ( + "Coves/internal/core/communities" + "Coves/tests/testkit" + "context" + "database/sql" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// What the four community sorts actually order by, against real SQL. +// +// tests/integration/community_e2e_test.go asserted this through the XRPC +// endpoint — a real PDS, a real Jetstream and a provisioned community per sort — +// and could only check that the endpoint answered 200, plus a pairwise +// alphabetical comparison over whatever communities happened to be in the shared +// database. The ordering is a property of one ORDER BY clause per sort, so it is +// tested here against a database seeded to make each sort produce a DIFFERENT +// answer: a test where every ordering agrees would pass with the switch +// statement deleted. +// +// The handler's half — which sort names are accepted, what an unknown one +// answers — is internal/api/handlers/community's list_test.go. + +// seedListableCommunity inserts one community with the counts and creation time +// a sort case needs. +// +// It writes through the repository rather than raw SQL so the row goes in the +// same way the consumer puts it there, and so a schema change breaks this in the +// same place it breaks production. +func seedListableCommunity(t *testing.T, repo communities.Repository, name, visibility string, subscribers, posts int, createdAt time.Time) *communities.Community { + t.Helper() + + community := &communities.Community{ + DID: "did:plc:list" + name, + Handle: "c-" + name + ".coves.social", + Name: name, + DisplayName: name, + OwnerDID: "did:plc:list" + name, + CreatedByDID: "did:plc:lister", + HostedByDID: "did:web:coves.social", + Visibility: visibility, + AllowExternalDiscovery: true, + SubscriberCount: subscribers, + PostCount: posts, + CreatedAt: createdAt, + UpdatedAt: createdAt, + RecordURI: "at://did:plc:list" + name + "/social.coves.community.profile/self", + } + + stored, err := repo.Create(context.Background(), community) + require.NoErrorf(t, err, "seeding community %s", name) + return stored +} + +// namesOf renders a listing's names in order, which is the whole assertion for +// a sort test and the only readable form for its failure message. +func namesOf(listed []*communities.Community) []string { + names := make([]string, len(listed)) + for i, c := range listed { + names[i] = c.Name + } + return names +} + +// seedSortFixture inserts three communities whose orderings differ under every +// sort, and returns the repository over them. +// +// name subscribers posts created +// alpha 1 30 oldest +// bravo 30 1 middle +// charlie 10 10 newest +// +// So: popular → bravo, charlie, alpha · active → alpha, charlie, bravo · +// new → charlie, bravo, alpha · alphabetical → alpha, bravo, charlie. No two +// sorts share an expected answer, which is what makes each assertion mean +// something. +func seedSortFixture(t *testing.T, db *sql.DB) communities.Repository { + t.Helper() + + repo := NewCommunityRepository(db) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + seedListableCommunity(t, repo, "alpha", "public", 1, 30, base) + seedListableCommunity(t, repo, "bravo", "public", 30, 1, base.Add(time.Hour)) + seedListableCommunity(t, repo, "charlie", "public", 10, 10, base.Add(2*time.Hour)) + return repo +} + +func TestCommunityRepo_ListSortOrdering(t *testing.T) { + t.Parallel() + + repo := seedSortFixture(t, testkit.DB(t)) + + for _, tc := range []struct { + sort string + expected []string + }{ + {sort: "popular", expected: []string{"bravo", "charlie", "alpha"}}, + {sort: "active", expected: []string{"alpha", "charlie", "bravo"}}, + {sort: "new", expected: []string{"charlie", "bravo", "alpha"}}, + {sort: "alphabetical", expected: []string{"alpha", "bravo", "charlie"}}, + // An unrecognised sort falls back to popular rather than to whatever + // order the planner returns. The handler rejects these before they get + // here, so this is the repository's own belt: a second caller (a feed + // job, a backfill) must not get an arbitrary order. + {sort: "", expected: []string{"bravo", "charlie", "alpha"}}, + {sort: "trending", expected: []string{"bravo", "charlie", "alpha"}}, + } { + t.Run("sort="+tc.sort, func(t *testing.T) { + listed, err := repo.List(context.Background(), communities.ListCommunitiesRequest{ + Limit: 10, + Sort: tc.sort, + }) + require.NoError(t, err) + assert.Equal(t, tc.expected, namesOf(listed)) + }) + } +} + +func TestCommunityRepo_ListPaginationFollowsTheSort(t *testing.T) { + t.Parallel() + + // Offset pagination over a sorted listing: page two must continue where page + // one stopped, in the same order. A sort applied after the LIMIT — or an + // offset applied to an unsorted query — would still return three distinct + // communities across two pages, so the assertion is on the ORDER, not the + // set. + repo := seedSortFixture(t, testkit.DB(t)) + ctx := context.Background() + + first, err := repo.List(ctx, communities.ListCommunitiesRequest{Limit: 2, Sort: "alphabetical"}) + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "bravo"}, namesOf(first)) + + second, err := repo.List(ctx, communities.ListCommunitiesRequest{Limit: 2, Offset: 2, Sort: "alphabetical"}) + require.NoError(t, err) + assert.Equal(t, []string{"charlie"}, namesOf(second)) + + past, err := repo.List(ctx, communities.ListCommunitiesRequest{Limit: 2, Offset: 10, Sort: "alphabetical"}) + require.NoError(t, err) + assert.Empty(t, past, "an offset past the end must be an empty page, not an error") +} + +func TestCommunityRepo_ListVisibilityFilter(t *testing.T) { + t.Parallel() + + db := testkit.DB(t) + repo := NewCommunityRepository(db) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + public := seedListableCommunity(t, repo, "openone", "public", 5, 5, base) + unlisted := seedListableCommunity(t, repo, "quietone", "unlisted", 5, 5, base) + + ctx := context.Background() + + listed, err := repo.List(ctx, communities.ListCommunitiesRequest{Limit: 10, Visibility: "public"}) + require.NoError(t, err) + assert.Equal(t, []string{public.Name}, namesOf(listed)) + + listed, err = repo.List(ctx, communities.ListCommunitiesRequest{Limit: 10, Visibility: "unlisted"}) + require.NoError(t, err) + assert.Equal(t, []string{unlisted.Name}, namesOf(listed)) + + // No filter means no filter: an unfiltered listing includes the unlisted + // community. Hiding it here instead of at the caller is how an "unlisted" + // community becomes an invisible one. + listed, err = repo.List(ctx, communities.ListCommunitiesRequest{Limit: 10, Sort: "alphabetical"}) + require.NoError(t, err) + assert.Equal(t, []string{public.Name, unlisted.Name}, namesOf(listed)) +} diff --git a/loop_state.md b/loop_state.md index d00031d..8dc9279 100644 --- a/loop_state.md +++ b/loop_state.md @@ -47,7 +47,7 @@ Stop the loop when every task is done, or on any blocked task. | 8 | Migrate remaining call sites; delete all 3 setupTestDB defs + per-file cleanup fns | 3 | M | done | (see git log) | DB MIGRATION COMPLETE: 128 sites (31 files incl. live+e2e), all 4 defs + 4 cleanup fns + 18 goose pairs + 63 wipes deleted, +189/−1182. grep setupTestDB|goose in tests/ = EMPTY. e2e shared-DB hazard was HYPOTHETICAL (user_signup setupTestDB had ZERO callers; error_recovery all in-process) — SharedDB not needed. TestMain → testkit.Main(RequirePostgres, RequirePDS, RequireJetstream): make test-integration now FAILS without dev stack instead of skip-green (spec-honest, kept). FULL -shuffle=on INTEGRATION RUN GREEN — wipes were dead weight. make ci GREEN 3399/0 @2:39 (+17s ≈ 133ms/clone, consistent) | | 9 | Global-state audit (t.Setenv/os.Setenv/logger/http-default → testkit injection); enable t.Parallel on proven-safe; connection budgets; `-race` clean; drop -p 1 | 3 ⛩ | S | done | (see git log) | PHASE 3 COMPLETE. 343 t.Parallel; audit: 0 convert / 4 sites deliberately-serial / rest safe. 9 internal straggler files migrated (goose now EXTINCT in test code; MigrateSharedDatabase deleted). THREE concurrency bugs -p 1 was masking: [A] template-destruction race (fixed: usePrivateTemplate) [B] legacy firehose 5s-behind-30s-promise, quantified (patched: jetstreamReadBudget, counter machinery deleted, non-timeout errors terminate) [C] Jetstream account/identity events BYPASS wantedCollections → parallel signup storms starve subscribers (measured 2/4 fail at -p 2; -p STAYS 1 with new documented reason). ConcurrencyBudget models both dims + nestedClonePools; -p 1 -parallel 26. Review: Codex good + Opus 3-high (binary-abort class, all fixed incl. fail-open Makefile splice PROVEN closed). make ci GREEN ×2 117/128s (clone tax repaid, beats 124s pre-clone); -race + -shuffle clean; peak 27/200 conns; 3401 tests/0 skips; audit 532 | | 10 | Contract-manifest CI check (WantedCollections ↔ //coves:ingestion-contract markers) + T2 skeleton (serial runner via compose runner; make test-e2e; test-e2e-dev escape hatch) | 4 ⛩ | S | done | (see git log) | THE PIPELINE WORKS: TestPipelineSmoke green in hermetic stack (direct PDS write → Jetstream → container consumers → getProfile, 0.95s de-raced). cmd/contract-manifest (38 tests, MatchFile-based, pending_contracts.txt ratchet w/ task ownership, AST forbidden-imports in marker files); T2 skeleton (newPipeline, contractBudget=45s, per-contract synthetic IPv6 vs the ONE-BUCKET rate limiter — A/B proven 60+40=100); make test-e2e via compose runner 48s cold (lib/ci-stack.sh + runner-ready.sh factored); zero-skip T2 enforcement. Census: 10 collections = task mapping exact. Review: Codex needs-work + Opus 3-high → 8 fixes (manifest bypasses had live probes; smoke de-raced vs profile-backfill reconciliation path). make ci GREEN ×2 ~2:00, 3448/0 | -| 11 | Contracts: community (community.profile ingestion + API) — strangler: behavior inventory of community_e2e_test.go (1820 LOC) → down-tier T1s → contract → delete old | 4 | S | pending | | template for tasks 12-16; sync-indexing trap per spec §3.4 | +| 11 | Contracts: community (community.profile ingestion + API) — strangler: behavior inventory of community_e2e_test.go (1820 LOC) → down-tier T1s → contract → delete old | 4 | S | done | (see git log) | TEMPLATE PROVEN. 22-behavior inventory; 2,168 LOC deleted, +14 net tests; 2/9 serial firehose files gone. Ingestion contract SELF-REGISTERS the community's PDS repo (stronger than arming — no sync write exists; consumer has NO must-know-first gate, verified). FOUND+FILED prod defect: unverifiable handles → handle.invalid UNIQUE squat → federated communities silently dropped; second symptom pds_url permanently empty → BridgeTrust denies bridged votes (issue extended). STANDING TIER LIMIT (spec §3.4b amended): sealed sessions mint only in browser OAuth — T2 covers auth boundary + reads; authenticated writes proven at T1; test-only mint = phase-5 pre-work. Review: Codex 1 high (update-handler boundary died — restored w/ 8 tests) + Opus audit 17/20 equal-or-stronger, 3 gaps all closed. make ci GREEN ×2 3496/0 @2:12 | | 12 | Contracts: post (community.post) + post_delete + decompose post god-files | 4 | S | pending | | | | 13 | Contracts: comment (community.comment) + comment god-files (1821+1443+1229+999 LOC) | 4 | S | pending | | biggest decomposition | | 14 | Contracts: vote (feed.vote) + user (actor.profile incl. avatar blob path) + subscription (community.subscription) | 4 | S | pending | | vote re-tap idempotency invariant | @@ -244,3 +244,21 @@ Stop the loop when every task is done, or on any blocked task. task 14 DELETES it with the vote contract, do not port or re-diagnose. COVES_CI_REBUILD=1 refreshes a kept stack's AppView (also resets limiter buckets). +- **From task 11 (TEMPLATE for tasks 12-15)**: copy community_contract_test.go's + form. Reuse provisionCommunityRepo (package-scoped, tests/e2e) to hang + posts/comments/votes on. SPIKE FIRST on a kept stack before writing any + contract — the handle.invalid discovery came from a throwaway spike, not + design. Records carrying their own handle skip PDS-host resolution + (pds_url stays empty — known defect, don't re-file). hostedBy + verification is OFF in CI (SKIP_DID_WEB_VERIFICATION) — contracts prove + field transport, not verification; say so in doc comments. 401 matrices + belong at T2 (only the running router shows a route that lost + RequireAuth); authenticated writes at T1. Posts wrinkle (from tidepool + cross-notes + survey): post consumer requires repo DID == record.community, + community indexed BEFORE post, author user indexed BEFORE post — post + records live in the COMMUNITY's repo, so the ingestion write uses the + community's own session. API asymmetry noted for a future task: update + silently overwrites client-supplied updatedByDid; create 400s on + createdByDid — pinned in tests, unify someday. internal/core/communities + tests are package communities_test (external, import cycle) — task 17 + must NOT add a second TestMain in package communities. diff --git a/tests/ci/pending_contracts.txt b/tests/ci/pending_contracts.txt index 7925281..e70e4c6 100644 --- a/tests/ci/pending_contracts.txt +++ b/tests/ci/pending_contracts.txt @@ -19,7 +19,6 @@ # Task 20 empties this file and flips cmd/contract-manifest to # -allow-pending=false, after which "has a contract" is the only passing state. -social.coves.community.profile # task 11: community ingestion contract, strangling community_e2e_test.go social.coves.community.post # task 12: post ingestion contract, with the post god-file decomposition social.coves.community.comment # task 13: comment ingestion contract, with the comment god-file decomposition social.coves.feed.vote # task 14: vote ingestion contract (the re-tap idempotency invariant lives here) diff --git a/tests/e2e/community_contract_test.go b/tests/e2e/community_contract_test.go new file mode 100644 index 0000000..a2107a6 --- /dev/null +++ b/tests/e2e/community_contract_test.go @@ -0,0 +1,474 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// The community domain's pipeline contracts: the ingestion proof for +// social.coves.community.profile, and the client-facing surface a third-party +// client actually reaches. +// +// # WHY THE INGESTION CONTRACT OWNS THE COMMUNITY'S REPO OUTRIGHT +// +// docs/TEST_ARCHITECTURE.md §3.4 names community creation as one of the two +// SYNCHRONOUS client paths — communities.CreateCommunity provisions the +// community's PDS account and writes the row itself ("the Jetstream consumer +// will eventually index the community profile from the firehose, but it won't +// have the PDS credentials. We must store them now"). Anything created that way +// is in Postgres before the firehose is consulted, so it proves nothing here. +// +// This contract therefore never asks the AppView for a community. It registers +// the community's PDS account itself, exactly as an account is registered for a +// user, and writes social.coves.community.profile/self into that repo with a +// session the AppView has never seen. Nothing in the AppView is aware the +// community exists until Jetstream says so, which makes the serving endpoint's +// answer un-fakeable in the strongest form the tier has: not "the sync write +// was disarmed", but "there was never a sync write". +// +// What that buys, beyond the ingestion proof, is the consumer's UNANNOUNCED-REPO +// path: a profile record arriving from a repo the AppView did not provision, so +// createCommunity really inserts a row instead of colliding with one the +// synchronous path already wrote. That is the path a federated community's +// records would take. +// +// It is NOT federation, and the difference is worth keeping straight because +// tasks 12-15 copy this file. The repo lives on the same single PDS the stack +// fronts, resolves through the same PLC, and is reachable by the same AppView +// credentials as everything else here; nothing about a second instance, a +// second PDS or cross-host identity is exercised. §3.4a is explicit that +// single-PDS direct writes are "honest direct-PDS-path testing, not +// federation", and real federation-path contracts are the second-PDS-plus-relay +// topology of Phase 5. +// +// # RECONCILIATION PATHS: NONE FOR THIS COLLECTION (checked, see contracts_test.go) +// +// The package doc's second hazard is code that reads the PDS on its own and can +// satisfy a wait with every consumer dead. For communities the search comes up +// empty, and the search is worth recording because the next reader should not +// have to repeat it: +// +// - users.maybeBackfillProfile, the known instance, is actor.profile-only. It +// is reached from users.IndexUser and touches the users table. +// - cmd/backfill-profiles is an operator CLI over the same user path. It is +// not wired into cmd/server and does not run in the stack. +// - the community read path (communities.GetCommunity → repo) never fetches a +// record; the only PDS reads in internal/core/communities are write-forwards +// (createRecordOnPDSAs / putRecordOnPDSAs) on the client path. +// +// So a single create → visible observation is already honest here, and the +// contract does not need the smoke test's arming write. +// +// # WHY THE RECORD CARRIES A handle FIELD (a finding, not a convenience) +// +// The consumer resolves a community's handle from the DID document when the +// record omits one (community_consumer.go, "NO FALLBACK - if PLC is down, we +// fail"), and resolution goes through indigo's BaseDirectory.LookupDID, which +// VERIFIES the declared handle bidirectionally: DNS TXT at _atproto., or +// HTTPS at https:///.well-known/atproto-did. The hermetic stack is +// egress-blocked by design (§3.7), so neither can answer, and indigo's contract +// for an unresolvable handle is not an error — it is syntax.HandleInvalid. +// +// Measured, in this stack: a community indexed from the firehose without a +// handle field lands with handle "handle.invalid". The communities table has a +// UNIQUE constraint on handle, so the SECOND such community collides, the +// consumer reads the collision as idempotent replay ("Community already +// indexed") and returns nil, and the community is silently dropped. A contract +// written that way would pass once per fresh stack and then quietly stop +// proving anything. +// +// Writing the handle into the record takes that branch out of the picture: the +// consumer uses the record's handle verbatim (the path +// community_v2_validation_test.go pins) and each community keeps its own. The +// cost is stated plainly — this contract does NOT exercise PLC handle +// resolution, and cannot until the stack can answer handle lookups, which is +// the second-PDS-and-relay topology of Phase 5. The silent-drop behaviour +// itself is a production defect (one unverifiable federated community squats +// the handle.invalid row for all the others) and is reported, not worked around +// here. +// +// # WHAT THIS CONTRACT DOES NOT PROVE: hostedBy VERIFICATION +// +// The stack sets SKIP_DID_WEB_VERIFICATION=true (.env.ci, matching .env.dev), +// which makes the consumer's verifyHostedByClaim return nil on its first line. +// So the hostedBy below is indexed because verification is OFF, not because it +// passed — and an assertion phrased as "the AppView accepted this hostedBy" +// would keep passing if the check were deleted outright. +// +// Nothing in this tier can fix that: verification fetches +// https:///.well-known/did.json, which an egress-blocked network cannot +// answer, and enabling it would fail every community event here for reasons +// that have nothing to do with the pipeline. The security behaviour — +// mismatched domains rejected, non-did:web hostedBy rejected, bidirectional +// alsoKnownAs required — is covered where it can be covered honestly: at T1, +// against a local TLS server that serves the DID document +// (tests/integration/community_hostedby_security_test.go). The hostedBy +// assertion below is about FIELD TRANSPORT, that the value in the record is the +// value the endpoint serves, and nothing more. +const ( + // communityProfileCollection is spelled out rather than imported, for the + // reason TestPipelineSmoke gives: it is a wire identifier the PDS, Jetstream + // and the consumer must independently agree on. + communityProfileCollection = "social.coves.community.profile" + + // communityInstanceDID is the AppView's instance identity, which in the CI + // stack is internal/config's compiled-in default: INSTANCE_DID is unset in + // .env.ci, so Instance.DID is "did:web:coves.social" and Instance.Domain is + // derived from it. + // + // A contract cannot read it from an endpoint — the AppView publishes its + // instance DID nowhere — so it is written down here, and the ingestion + // contract asserts that the hostedBy it wrote is the hostedBy served back. + // The domain half is derived from this one value rather than spelled twice, + // and it must stay inside the PDS' PDS_SERVICE_HANDLE_DOMAINS, which is what + // allows c-*.coves.social accounts to be registered at all. + communityInstanceDID = "did:web:coves.social" +) + +// communityInstanceDomain is the domain half of communityInstanceDID: the +// suffix community handles are issued under. +func communityInstanceDomain() string { + return strings.TrimPrefix(communityInstanceDID, "did:web:") +} + +// communityHandleFor renders the canonical handle of a community named name: +// the c- prefix and the instance domain that internal/core/communities' +// provisioner uses (pds_provisioning.go, "c-%s.%s"). +func communityHandleFor(name string) string { + return "c-" + name + "." + communityInstanceDomain() +} + +// communityView is the slice of social.coves.community.get's response that the +// contracts observe. As with ProfileView, modelling only the asserted fields +// keeps a new lexicon field from breaking every contract that reads a community. +type communityView struct { + DID string `json:"did"` + Handle string `json:"handle"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + CreatedBy string `json:"createdBy"` + HostedBy string `json:"hostedBy"` + Visibility string `json:"visibility"` + SubscriberCount int `json:"subscriberCount"` +} + +// Community reads a community from the AppView by any identifier the endpoint +// accepts: a DID, a canonical handle, or the scoped !name@instance form. +func (p *pipeline) Community(ctx context.Context, identifier string) (communityView, error) { + var view communityView + err := p.AppView.Query(ctx, "social.coves.community.get", + url.Values{"community": {identifier}}, &view) + return view, err +} + +// provisionedCommunity is a community's repo: the PDS account that owns it and +// the name every identifier form is derived from. +type provisionedCommunity struct { + *testkit.Account + Name string +} + +// provisionCommunityRepo registers the PDS account a community's records live +// in, and returns a session on it. +// +// It is the community analogue of pipeline.IndexedAccount, with one difference +// that matters: there is no signup step and nothing tells the AppView this +// account exists. Communities enter the index through the profile record alone +// — the community consumer, unlike the user consumer, indexes repos it has +// never seen — so the account is invisible until a record is written to it. +// +// Tasks 12-15 need communities to hang posts, comments and votes on; this is +// the cheap way to get one that the AppView learned about honestly. +func provisionCommunityRepo(t *testing.T, p *pipeline, prefix string) provisionedCommunity { + t.Helper() + + name := testkit.UniqueIDWithPrefix(t, prefix) + handle := communityHandleFor(name) + // The PDS caps a handle's local label at 18 characters, and the label here + // is "c-" plus the name. Checked rather than assumed: over the cap, account + // creation fails with "Handle too long", which reads like a PDS problem + // rather than a naming-budget problem. + if label := strings.SplitN(handle, ".", 2)[0]; len(label) > testkit.MaxIDLength { + t.Fatalf("community handle label %q is %d characters, over the PDS' %d-character cap: "+ + "shorten the %q prefix", label, len(label), testkit.MaxIDLength, prefix) + } + + account := p.PDS.CreateAccount(t, + testkit.WithHandle(handle), + testkit.WithEmail(name+"@community.test.coves.dev")) + return provisionedCommunity{Account: account, Name: name} +} + +// communityProfile builds a social.coves.community.profile record in the shape +// internal/core/communities writes it (plus the handle field the package doc +// explains), so the consumer parses exactly what production hands it. +func communityProfile(c provisionedCommunity, creatorDID, displayName, description, visibility string) map[string]any { + return map[string]any{ + "$type": communityProfileCollection, + "name": c.Name, + "handle": c.Handle, + "displayName": displayName, + "description": description, + "visibility": visibility, + "createdBy": creatorDID, + "hostedBy": communityInstanceDID, + "createdAt": time.Now().UTC().Format(time.RFC3339), + "federation": map[string]any{"allowExternalDiscovery": true}, + } +} + +// TestCommunityProfileIngestion is the pipeline proof for community profiles. +// +// coves:ingestion-contract social.coves.community.profile +// +// Create, update and delete, each written straight into the community's own +// repo and each observed only through social.coves.community.get: +// +// create → the community appears, carrying the record's own field values +// update → the same DID serves the new values +// delete → the community is gone, and STAYS gone (Holds, §3.4a) +// +// The delete assertion is the one worth being precise about, because the +// consumer's choice is not obvious from the outside: deleteCommunity calls +// repo.Delete, which is `DELETE FROM communities WHERE did = $1` — a hard +// delete, not a tombstone. So the correct post-delete observation is the +// endpoint's 404, not an emptied row, and Holds is what distinguishes a real +// delete from one a replayed create resurrects a second later. +func TestCommunityProfileIngestion(t *testing.T) { + p := newPipeline(t) + + // A real indexed user for createdBy: communities are created by somebody, + // and a DID the AppView knows keeps the row honest for anything that later + // joins communities to users. + creator := p.IndexedAccount(t, "cmt") + community := provisionCommunityRepo(t, p, "c") + + created := "created " + testkit.UniqueID(t) + updated := "updated " + testkit.UniqueID(t) + + observe := func(description string, accept func(communityView) bool) communityView { + t.Helper() + var observed communityView + p.Await(t, description, func() (bool, error) { + view, err := p.Community(context.Background(), community.DID) + if done, err := testkit.PendingIfNotFound(err); !done || err != nil { + return done, err + } + observed = view + return accept(view), nil + }) + return observed + } + + // ---- create ----------------------------------------------------------- + community.PutRecord(t, communityProfileCollection, "self", + communityProfile(community, creator.DID, created, "a community the AppView never provisioned", "public")) + + view := observe("the directly-written community profile to reach social.coves.community.get via the consumers", + func(v communityView) bool { return v.DisplayName == created }) + + require.Equal(t, community.DID, view.DID, + "the AppView served a different community than the one that owns the repo") + require.Equal(t, community.Handle, view.Handle, + "the handle in the record is what the consumer must index") + require.Equal(t, community.Name, view.Name) + require.Equal(t, "a community the AppView never provisioned", view.Description) + require.Equal(t, creator.DID, view.CreatedBy) + // Field transport only — verification is off in this stack, see the package + // doc. This says the record's hostedBy reached the endpoint intact, not that + // the AppView checked it. + require.Equal(t, communityInstanceDID, view.HostedBy) + require.Equal(t, "public", view.Visibility) + + // ---- update ----------------------------------------------------------- + // Same rkey, so this is an update commit rather than a second create — the + // consumer's updateCommunity path, which reads the existing row and writes + // the changed fields back. + community.PutRecord(t, communityProfileCollection, "self", + communityProfile(community, creator.DID, updated, "edited through the firehose", "unlisted")) + + view = observe("the updated profile to reach social.coves.community.get", + func(v communityView) bool { return v.DisplayName == updated }) + + require.Equal(t, "edited through the firehose", view.Description) + require.Equal(t, "unlisted", view.Visibility, + "the update path must carry every changed field, not only the display name") + require.Equal(t, community.DID, view.DID) + + // ---- delete ----------------------------------------------------------- + // DeleteExistingRecord rather than DeleteRecord: deleting a key that is not + // there answers 200 and emits no commit, so a wrong rkey would turn into a + // timeout blaming the firehose (testkit/pds.go). + community.DeleteExistingRecord(t, communityProfileCollection, "self") + + gone := func() (bool, error) { + _, err := p.Community(context.Background(), community.DID) + switch { + case err == nil: + return false, nil + case testkit.IsNotFound(err): + return true, nil + default: + return false, err + } + } + p.Await(t, "the deleted community to disappear from social.coves.community.get", gone) + p.Holds(t, "the deleted community to stay deleted", gone) +} + +// TestCommunityAPIContract covers the client-facing surface of the community +// endpoints as a third-party client meets it: what an unauthenticated caller +// gets from the write endpoints, and what any caller can read back about a +// community that exists. +// +// It carries NO ingestion marker — markers are for pipeline proofs (§3.4a), and +// this asserts the client path. +// +// # WHAT IS MISSING FROM IT, AND WHY (a finding for tasks 12-15) +// +// §3.4b asks this contract to drive the write endpoints "exactly as the mobile +// app calls them", and it cannot, because there is no non-interactive way for a +// test to hold an AppView session. OAuthAuthMiddleware.RequireAuth accepts one +// credential: a sealed session token (internal/atproto/oauth/seal.go) naming a +// row in the OAuth session store. Tokens are minted in exactly one place — +// /oauth/callback, at the end of the browser authorization-code flow against +// the PDS' own HTML login pages — and nothing else issues one: +// social.coves.actor.signup returns the PDS' accessJwt, which RequireAuth +// rejects (it is not sealed), and /oauth/refresh requires a sealed token to +// begin with. +// +// The two ways out are both bigger than one contract: drive the PDS' OAuth +// consent pages from Go (fragile against a PDS the project does not own), or +// give the AppView a test-only session-minting path (a production change, with +// the obvious care about how it is gated). The integration tier sidesteps it by +// constructing the session in-process — tests/integration/oauth_e2e_test.go +// calls store.SaveSession then client.SealSession — which T2 cannot do without +// writing to the AppView's own database, the one thing the package doc forbids. +// +// So the authenticated half of every write endpoint family (create, update, +// subscribe, block, and the post/comment/vote endpoints tasks 12-15 will meet) +// is proven at T1 today: handler behaviour against a mock service in +// internal/api/handlers/community, and write-forward record shape against a +// real PDS in internal/core/communities. What this contract adds on top is the +// part T1 cannot see — that the shipped binary really does route these NSIDs, +// really does guard them, and really does serve an indexed community back. +func TestCommunityAPIContract(t *testing.T) { + p := newPipeline(t) + creator := p.IndexedAccount(t, "api") + community := provisionCommunityRepo(t, p, "a") + + displayName := "api contract " + testkit.UniqueID(t) + community.PutRecord(t, communityProfileCollection, "self", + communityProfile(community, creator.DID, displayName, "read back through the client surface", "public")) + + p.Await(t, "the community to be indexed before the client surface is exercised", + func() (bool, error) { + view, err := p.Community(context.Background(), community.DID) + if done, err := testkit.PendingIfNotFound(err); !done || err != nil { + return done, err + } + return view.DisplayName == displayName, nil + }) + + ctx, cancel := context.WithTimeout(context.Background(), contractBudget) + defer cancel() + + t.Run("the write endpoints refuse an unauthenticated client", func(t *testing.T) { + // One request each, no polling: this is the auth boundary of the shipped + // router, and the answer does not become true later. + // + // Every community NSID that RegisterCommunityRoutes puts behind + // RequireAuth is listed, including the four "un-" halves. That + // completeness is the point of asserting it HERE rather than only in the + // handler tests: a handler test proves the handler refuses an + // unauthenticated call, and cannot see a route that was registered + // without the middleware in front of it. Only a request to the running + // router can. + for _, endpoint := range []struct { + nsid string + input map[string]any + }{ + {"social.coves.community.create", map[string]any{"name": testkit.UniqueIDWithPrefix(t, "n"), "displayName": "nope"}}, + {"social.coves.community.update", map[string]any{"communityDid": community.DID, "displayName": "nope"}}, + {"social.coves.community.subscribe", map[string]any{"community": community.DID}}, + {"social.coves.community.unsubscribe", map[string]any{"community": community.DID}}, + {"social.coves.community.blockCommunity", map[string]any{"community": community.DID}}, + {"social.coves.community.unblockCommunity", map[string]any{"community": community.DID}}, + } { + err := p.AppView.Procedure(ctx, endpoint.nsid, endpoint.input, nil) + require.Truef(t, testkit.IsStatus(err, http.StatusUnauthorized), + "%s must answer 401 to a client with no session, answered: %v", endpoint.nsid, err) + } + }) + + t.Run("a client reads the community by every identifier form", func(t *testing.T) { + // The three shapes internal/core/communities.ResolveCommunityIdentifier + // accepts, checked here against the running router rather than the + // service, because a handler that forgets to pass the parameter through + // fails only at this level. + scoped := "!" + community.Name + "@" + communityInstanceDomain() + for _, identifier := range []string{community.DID, community.Handle, scoped} { + view, err := p.Community(ctx, identifier) + require.NoErrorf(t, err, "social.coves.community.get rejected identifier %q", identifier) + require.Equalf(t, community.DID, view.DID, + "identifier %q resolved to the wrong community", identifier) + require.Equal(t, displayName, view.DisplayName) + } + }) + + t.Run("an unknown community is a not-found, not a router miss", func(t *testing.T) { + // XRPC-shaped, which is what testkit.IsNotFound insists on: a plain 404 + // would mean the route is gone, and every wait in the tier that treats + // "not found" as "not yet" depends on being able to tell those apart. + _, err := p.Community(ctx, "did:plc:"+testkit.UniqueID(t)) + require.Truef(t, testkit.IsNotFound(err), "expected an XRPC not-found, got: %v", err) + require.True(t, testkit.IsStatus(err, http.StatusNotFound)) + }) + + t.Run("the community is listed", func(t *testing.T) { + // sort=new, so the assertion does not depend on how many communities a + // kept stack has accumulated: this one was created last. + var listed struct { + Communities []communityView `json:"communities"` + Cursor string `json:"cursor"` + } + err := p.AppView.Query(ctx, "social.coves.community.list", + url.Values{"sort": {"new"}, "limit": {"25"}}, &listed) + require.NoError(t, err) + + var found bool + for _, c := range listed.Communities { + if c.DID == community.DID { + found = true + require.Equal(t, community.Handle, c.Handle) + require.Equal(t, displayName, c.DisplayName) + } + } + require.Truef(t, found, "the community %s was not in the newest %d communities: %s", + community.DID, len(listed.Communities), summarize(listed.Communities)) + }) +} + +// summarize renders a community list compactly for a failure message. +func summarize(views []communityView) string { + if len(views) == 0 { + return "(the list was empty)" + } + parts := make([]string, 0, len(views)) + for _, v := range views { + parts = append(parts, fmt.Sprintf("%s(%s)", v.Handle, v.DID)) + } + return strings.Join(parts, ", ") +} diff --git a/tests/integration/community_consumer_test.go b/tests/integration/community_consumer_test.go index 647f5b2..90b0514 100644 --- a/tests/integration/community_consumer_test.go +++ b/tests/integration/community_consumer_test.go @@ -82,6 +82,22 @@ func TestCommunityConsumer_HandleCommunityProfile(t *testing.T) { if community.Visibility != "public" { t.Errorf("Expected Visibility 'public', got %s", community.Visibility) } + + // V2: the record URI the consumer builds must point at the COMMUNITY's + // own repository, not at the instance's. The consumer constructs it from + // the event's repo DID (community_consumer.go: "at://%s/…/self"), so a + // regression here would silently record every federated community's + // profile as living in whichever repo the AppView happened to think it + // owned — and nothing else in the consumer's own tests looks at the URI. + // This assertion came from tests/integration/community_e2e_test.go's + // Part 2, which needed a real PDS and a real Jetstream to make it. + expectedURI := fmt.Sprintf("at://%s/social.coves.community.profile/self", communityDID) + if community.RecordURI != expectedURI { + t.Errorf("Expected RecordURI %s, got %s", expectedURI, community.RecordURI) + } + if community.RecordCID != "bafy123abc" { + t.Errorf("Expected the commit's CID bafy123abc, got %s", community.RecordCID) + } }) t.Run("updates existing community", func(t *testing.T) { diff --git a/tests/integration/community_e2e_test.go b/tests/integration/community_e2e_test.go deleted file mode 100644 index d62d295..0000000 --- a/tests/integration/community_e2e_test.go +++ /dev/null @@ -1,1787 +0,0 @@ -//go:build integration - -package integration - -// SERIAL BY DESIGN — do not add t.Parallel() to this file. -// -// Its tests drive the Jetstream firehose through the hand-rolled -// subscribeToJetstream* helpers below rather than testkit's cursor-gated -// subscriber. Those helpers subscribe to one shared stream and match on the -// first event of a collection, so a concurrent test writing the same -// collection is delivered to them too and either steals the match or trips -// their timeout. Per-test database clones do not isolate a shared websocket. -// -// docs/TEST_ARCHITECTURE.md §3.3 ("Parallelism is earned, not assumed"). - -import ( - "Coves/internal/api/routes" - "Coves/internal/atproto/identity" - "Coves/internal/atproto/jetstream" - "Coves/internal/atproto/utils" - "Coves/internal/core/communities" - "Coves/internal/core/users" - "Coves/internal/db/postgres" - "Coves/tests/testkit" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" - "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/go-chi/chi/v5" - "github.com/gorilla/websocket" -) - -// TestCommunity_E2E is a TRUE end-to-end test covering the complete flow: -// 1. HTTP Endpoint → Service Layer → PDS Account Creation → PDS Record Write -// 2. PDS → REAL Jetstream Firehose → Consumer → AppView DB (TRUE E2E!) -// 3. AppView DB → XRPC HTTP Endpoints → Client -// -// This test verifies: -// - V2: Community owns its own PDS account and repository -// - V2: Record URI points to community's repo (at://community_did/...) -// - Real Jetstream firehose subscription and event consumption -// - Complete data flow from HTTP write to HTTP read via real infrastructure -func TestCommunity_E2E(t *testing.T) { - - db := testkit.DB(t) - - // Check if PDS is running - pdsURL := os.Getenv("PDS_URL") - if pdsURL == "" { - pdsURL = "http://localhost:3001" - } - - healthResp, err := http.Get(pdsURL + "/xrpc/_health") - if err != nil { - t.Skipf("PDS not running at %s: %v", pdsURL, err) - } - func() { - if closeErr := healthResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close health response: %v", closeErr) - } - }() - - // Setup dependencies - communityRepo := postgres.NewCommunityRepository(db) - - // Create a fresh test account on PDS (similar to user_journey_e2e_test pattern). - // uniqueTestID() (Unix seconds + atomic counter) guarantees uniqueness across runs; - // the prior Unix%100000 + UnixNano%10000 scheme could collide ("handle already taken"). - uniqueID := uniqueTestID() - instanceHandle := fmt.Sprintf("ce%s.local.coves.dev", uniqueID) - instanceEmail := fmt.Sprintf("comm%s@test.com", uniqueID) - instancePassword := "test-password-community-123" - - t.Logf("🔐 Creating test account on PDS: %s", instanceHandle) - - // Create account on PDS - this returns the access token and DID - accessToken, instanceDID, err := createPDSAccount(pdsURL, instanceHandle, instanceEmail, instancePassword) - if err != nil { - t.Fatalf("Failed to create account on PDS: %v", err) - } - - t.Logf("✅ Account created - Instance DID: %s", instanceDID) - - // Initialize OAuth auth middleware for E2E testing - e2eAuth := NewE2EOAuthMiddleware() - // Register the instance user with their REAL PDS access token for write-forward operations - token := e2eAuth.AddUserWithPDSToken(instanceDID, accessToken, pdsURL) - - // V2.0: Extract instance domain for community provisioning - var instanceDomain string - if strings.HasPrefix(instanceDID, "did:web:") { - instanceDomain = strings.TrimPrefix(instanceDID, "did:web:") - } else { - // Use .social for testing (not .local - that TLD is disallowed by atProto) - instanceDomain = "coves.social" - } - - // V2.0: Create user service with REAL identity resolution using local PLC - plcURL := os.Getenv("PLC_DIRECTORY_URL") - if plcURL == "" { - plcURL = "http://localhost:3002" // Local PLC directory - } - userRepo := postgres.NewUserRepository(db) - identityConfig := identity.DefaultConfig() - identityConfig.PLCURL = plcURL // Use local PLC for identity resolution - identityResolver := identity.NewResolver(db, identityConfig) - _ = users.NewUserService(userRepo, identityResolver, pdsURL, nil, "") // Keep for potential future use - t.Logf("✅ Identity resolver configured with local PLC: %s", plcURL) - - // V2.0: Initialize PDS account provisioner (simplified - no DID generator needed!) - // PDS handles all DID generation and registration automatically - provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) - - // Create service with PDS factory for password-based auth in tests - communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, instanceDID, instanceDomain, provisioner, CommunityPasswordAuthPDSClientFactory(), nil) - if svc, ok := communityService.(interface{ SetPDSAccessToken(string) }); ok { - svc.SetPDSAccessToken(accessToken) - } - - // Use real identity resolver with local PLC for production-like testing - consumer := jetstream.NewCommunityEventConsumer(communityRepo, "did:web:coves.local", true, identityResolver) - - // Setup HTTP server with XRPC routes - r := chi.NewRouter() - routes.RegisterCommunityRoutes(r, communityService, communityRepo, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators - httpServer := httptest.NewServer(r) - defer httpServer.Close() - - ctx := context.Background() - - // ==================================================================================== - // Part 1: Write-Forward to PDS (Service Layer) - // ==================================================================================== - t.Run("1. Write-Forward to PDS", func(t *testing.T) { - // Use shorter names to avoid "Handle too long" errors - // atProto handles max: 63 chars, format: c-name.coves.social - communityName := testkit.UniqueIDWithPrefix(t, "e2e") - - createReq := communities.CreateCommunityRequest{ - Name: communityName, - DisplayName: "E2E Test Community", - Description: "Testing full E2E flow", - Visibility: "public", - CreatedByDID: instanceDID, - HostedByDID: instanceDID, - AllowExternalDiscovery: true, - } - - t.Logf("\n📝 Creating community via service: %s", communityName) - // Capture a Jetstream replay cursor BEFORE the write so the Part 2 subscription - // (opened after the write) cannot miss the resulting firehose commit. - communityCreateCursor := jetstreamCursorNow() - community, err := communityService.CreateCommunity(ctx, createReq) - if err != nil { - t.Fatalf("Failed to create community: %v", err) - } - - t.Logf("✅ Service returned:") - t.Logf(" DID: %s", community.DID) - t.Logf(" Handle: %s", community.Handle) - t.Logf(" RecordURI: %s", community.RecordURI) - t.Logf(" RecordCID: %s", community.RecordCID) - - // Verify DID format - if community.DID[:8] != "did:plc:" { - t.Errorf("Expected did:plc DID, got: %s", community.DID) - } - - // V2: Verify PDS account was created for the community - t.Logf("\n🔍 V2: Verifying community PDS account exists...") - expectedHandle := fmt.Sprintf("c-%s.%s", communityName, instanceDomain) - t.Logf(" Expected handle: %s", expectedHandle) - t.Logf(" (Using subdomain: c-*.%s)", instanceDomain) - - accountDID, accountHandle, err := queryPDSAccount(pdsURL, expectedHandle) - if err != nil { - t.Fatalf("❌ V2: Community PDS account not found: %v", err) - } - - t.Logf("✅ V2: Community PDS account exists!") - t.Logf(" Account DID: %s", accountDID) - t.Logf(" Account Handle: %s", accountHandle) - - // Verify the account DID matches the community DID - if accountDID != community.DID { - t.Errorf("❌ V2: Account DID mismatch! Community DID: %s, PDS Account DID: %s", - community.DID, accountDID) - } else { - t.Logf("✅ V2: Community DID matches PDS account DID (self-owned repository)") - } - - // V2: Verify record exists in PDS (in community's own repository) - t.Logf("\n📡 V2: Querying PDS for record in community's repository...") - - collection := "social.coves.community.profile" - rkey := utils.ExtractRKeyFromURI(community.RecordURI) - - // V2: Query community's repository (not instance repository!) - getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, community.DID, collection, rkey) - - t.Logf(" Querying: at://%s/%s/%s", community.DID, collection, rkey) - - pdsResp, err := http.Get(getRecordURL) - if err != nil { - t.Fatalf("Failed to query PDS: %v", err) - } - defer func() { _ = pdsResp.Body.Close() }() - - if pdsResp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(pdsResp.Body) - if readErr != nil { - t.Fatalf("PDS returned status %d (failed to read body: %v)", pdsResp.StatusCode, readErr) - } - t.Fatalf("PDS returned status %d: %s", pdsResp.StatusCode, string(body)) - } - - var pdsRecord struct { - Value map[string]interface{} `json:"value"` - URI string `json:"uri"` - CID string `json:"cid"` - } - - if err := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); err != nil { - t.Fatalf("Failed to decode PDS response: %v", err) - } - - t.Logf("✅ Record found in PDS!") - t.Logf(" URI: %s", pdsRecord.URI) - t.Logf(" CID: %s", pdsRecord.CID) - - // Print full record for inspection - recordJSON, marshalErr := json.MarshalIndent(pdsRecord.Value, " ", " ") - if marshalErr != nil { - t.Logf(" Failed to marshal record: %v", marshalErr) - } else { - t.Logf(" Record value:\n %s", string(recordJSON)) - } - - // V2: DID and Handle are NOT in the record - they're resolved from the repository URI - // The record should have name, hostedBy, createdBy, etc. but no 'did' or 'handle' fields - // This matches Bluesky's app.bsky.actor.profile pattern (no handle in record) - // Handles are mutable and resolved from DIDs via PLC, so they shouldn't be stored in immutable records - - // ==================================================================================== - // Part 2: TRUE E2E - Real Jetstream Firehose Consumer - // ==================================================================================== - t.Run("2. Real Jetstream Firehose Consumption", func(t *testing.T) { - t.Logf("\n🔄 TRUE E2E: Subscribing to real Jetstream firehose...") - - // Get PDS hostname for Jetstream filtering - pdsHostname := strings.TrimPrefix(pdsURL, "http://") - pdsHostname = strings.TrimPrefix(pdsHostname, "https://") - pdsHostname = strings.Split(pdsHostname, ":")[0] // Remove port - - // Build Jetstream URL with filters - // Filter to our PDS and social.coves.community.profile collection - jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", - pdsHostname) - - t.Logf(" Jetstream URL: %s", jetstreamURL) - t.Logf(" Looking for community DID: %s", community.DID) - - // Channel to receive the event - eventChan := make(chan *jetstream.JetstreamEvent, 10) - errorChan := make(chan error, 1) - done := make(chan bool) - - // Start Jetstream consumer in background - go func() { - err := subscribeToJetstream(ctx, withJetstreamCursor(jetstreamURL, communityCreateCursor), community.DID, consumer, eventChan, errorChan, done) - if err != nil { - errorChan <- err - } - }() - - // Wait for event or timeout - t.Logf("⏳ Waiting for Jetstream event (max 30 seconds)...") - - select { - case event := <-eventChan: - t.Logf("✅ Received real Jetstream event!") - t.Logf(" Event DID: %s", event.Did) - t.Logf(" Collection: %s", event.Commit.Collection) - t.Logf(" Operation: %s", event.Commit.Operation) - t.Logf(" RKey: %s", event.Commit.RKey) - - // Verify it's our community - if event.Did != community.DID { - t.Errorf("❌ Expected DID %s, got %s", community.DID, event.Did) - } - - // Verify indexed in AppView database - t.Logf("\n🔍 Querying AppView database...") - - indexed, err := communityRepo.GetByDID(ctx, community.DID) - if err != nil { - t.Fatalf("Community not indexed in AppView: %v", err) - } - - t.Logf("✅ Community indexed in AppView:") - t.Logf(" DID: %s", indexed.DID) - t.Logf(" Handle: %s", indexed.Handle) - t.Logf(" DisplayName: %s", indexed.DisplayName) - t.Logf(" RecordURI: %s", indexed.RecordURI) - - // V2: Verify record_uri points to COMMUNITY's own repo - expectedURIPrefix := "at://" + community.DID - if !strings.HasPrefix(indexed.RecordURI, expectedURIPrefix) { - t.Errorf("❌ V2: record_uri should point to community's repo\n Expected prefix: %s\n Got: %s", - expectedURIPrefix, indexed.RecordURI) - } else { - t.Logf("✅ V2: Record URI correctly points to community's own repository") - } - - // Signal to stop Jetstream consumer - close(done) - - case err := <-errorChan: - t.Fatalf("❌ Jetstream error: %v", err) - - case <-time.After(30 * time.Second): - t.Fatalf("❌ Timeout: No Jetstream event received within 30 seconds") - } - - t.Logf("\n✅ Part 2 Complete: TRUE E2E - PDS → Jetstream → Consumer → AppView ✓") - }) - }) - - // ==================================================================================== - // Part 3: XRPC HTTP Endpoints - // ==================================================================================== - t.Run("3. XRPC HTTP Endpoints", func(t *testing.T) { - t.Run("Create via XRPC endpoint", func(t *testing.T) { - // Use Unix timestamp (seconds) instead of UnixNano to keep handle short - // NOTE: Both createdByDid and hostedByDid are derived server-side: - // - createdByDid: from JWT token (authenticated user) - // - hostedByDid: from instance configuration (security: prevents spoofing) - createReq := map[string]interface{}{ - "name": testkit.UniqueIDWithPrefix(t, "xrpc"), - "displayName": "XRPC E2E Test", - "description": "Testing true end-to-end flow", - "visibility": "public", - "allowExternalDiscovery": true, - } - - reqBody, marshalErr := json.Marshal(createReq) - if marshalErr != nil { - t.Fatalf("Failed to marshal request: %v", marshalErr) - } - - // Step 1: Client POSTs to XRPC endpoint with JWT authentication - t.Logf("📡 Client → POST /xrpc/social.coves.community.create") - t.Logf(" Request: %s", string(reqBody)) - - req, err := http.NewRequest(http.MethodPost, - httpServer.URL+"/xrpc/social.coves.community.create", - bytes.NewBuffer(reqBody)) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - // Use OAuth token for Coves API authentication - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Logf("❌ XRPC Create Failed") - t.Logf(" Status: %d", resp.StatusCode) - t.Logf(" Response: %s", string(body)) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var createResp struct { - URI string `json:"uri"` - CID string `json:"cid"` - DID string `json:"did"` - Handle string `json:"handle"` - } - - if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { - t.Fatalf("Failed to decode create response: %v", err) - } - - t.Logf("✅ XRPC response received:") - t.Logf(" DID: %s", createResp.DID) - t.Logf(" Handle: %s", createResp.Handle) - t.Logf(" URI: %s", createResp.URI) - - // Step 2: Simulate firehose consumer picking up the event - // NOTE: Using synthetic event for speed. Real Jetstream WebSocket testing - // happens in "Part 2: Real Jetstream Firehose Consumption" above. - t.Logf("🔄 Simulating Jetstream consumer indexing...") - rkey := utils.ExtractRKeyFromURI(createResp.URI) - // V2: Event comes from community's DID (community owns the repo) - event := jetstream.JetstreamEvent{ - Did: createResp.DID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-rev", - Operation: "create", - Collection: "social.coves.community.profile", - RKey: rkey, - Record: map[string]interface{}{ - // Note: No 'did' or 'handle' in record (atProto best practice) - // These are mutable and resolved from DIDs, not stored in immutable records - "name": createReq["name"], - "displayName": createReq["displayName"], - "description": createReq["description"], - "visibility": createReq["visibility"], - // Server-side derives these from JWT auth (instanceDID is the authenticated user) - "owner": instanceDID, - "createdBy": instanceDID, - "hostedBy": instanceDID, - "federation": map[string]interface{}{ - "allowExternalDiscovery": createReq["allowExternalDiscovery"], - }, - "createdAt": time.Now().Format(time.RFC3339), - }, - CID: createResp.CID, - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &event); handleErr != nil { - t.Logf("Warning: failed to handle event: %v", handleErr) - } - - // Step 3: Verify it's indexed in AppView - t.Logf("🔍 Querying AppView to verify indexing...") - var indexedCommunity communities.Community - err = db.QueryRow(` - SELECT did, handle, display_name, description - FROM communities - WHERE did = $1 - `, createResp.DID).Scan( - &indexedCommunity.DID, - &indexedCommunity.Handle, - &indexedCommunity.DisplayName, - &indexedCommunity.Description, - ) - if err != nil { - t.Fatalf("Community not indexed in AppView: %v", err) - } - - t.Logf("✅ TRUE E2E FLOW COMPLETE:") - t.Logf(" Client → XRPC → PDS → Firehose → AppView ✓") - t.Logf(" Indexed community: %s (%s)", indexedCommunity.Handle, indexedCommunity.DisplayName) - }) - - t.Run("Get via XRPC endpoint", func(t *testing.T) { - // Create a community first (via service, so it's indexed) - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - // GET via HTTP endpoint - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.get?community=%s", - httpServer.URL, community.DID)) - if err != nil { - t.Fatalf("Failed to GET: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var getCommunity communities.Community - if err := json.NewDecoder(resp.Body).Decode(&getCommunity); err != nil { - t.Fatalf("Failed to decode get response: %v", err) - } - - t.Logf("Retrieved via XRPC HTTP endpoint:") - t.Logf(" DID: %s", getCommunity.DID) - t.Logf(" DisplayName: %s", getCommunity.DisplayName) - - if getCommunity.DID != community.DID { - t.Errorf("DID mismatch: expected %s, got %s", community.DID, getCommunity.DID) - } - }) - - t.Run("List via XRPC endpoint", func(t *testing.T) { - // Create and index multiple communities - for i := 0; i < 3; i++ { - createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - } - - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var listResp struct { - Cursor string `json:"cursor"` - Communities []communities.Community `json:"communities"` - } - - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - t.Fatalf("Failed to decode list response: %v", err) - } - - t.Logf("✅ Listed %d communities via XRPC", len(listResp.Communities)) - - if len(listResp.Communities) < 3 { - t.Errorf("Expected at least 3 communities, got %d", len(listResp.Communities)) - } - }) - - t.Run("List with sort=popular (default)", func(t *testing.T) { - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?sort=popular&limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with sort=popular: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var listResp struct { - Cursor string `json:"cursor"` - Communities []communities.Community `json:"communities"` - } - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - t.Logf("✅ Listed %d communities sorted by popular (subscriber_count DESC)", len(listResp.Communities)) - }) - - t.Run("List with sort=active", func(t *testing.T) { - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?sort=active&limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with sort=active: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - t.Logf("✅ Listed communities sorted by active (post_count DESC)") - }) - - t.Run("List with sort=new", func(t *testing.T) { - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?sort=new&limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with sort=new: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - t.Logf("✅ Listed communities sorted by new (created_at DESC)") - }) - - t.Run("List with sort=alphabetical", func(t *testing.T) { - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?sort=alphabetical&limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with sort=alphabetical: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var listResp struct { - Cursor string `json:"cursor"` - Communities []communities.Community `json:"communities"` - } - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - // Verify alphabetical ordering - if len(listResp.Communities) > 1 { - for i := 0; i < len(listResp.Communities)-1; i++ { - if listResp.Communities[i].Name > listResp.Communities[i+1].Name { - t.Errorf("Communities not in alphabetical order: %s > %s", - listResp.Communities[i].Name, listResp.Communities[i+1].Name) - } - } - } - - t.Logf("✅ Listed communities sorted alphabetically (name ASC)") - }) - - t.Run("List with invalid sort value", func(t *testing.T) { - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?sort=invalid&limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with invalid sort: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusBadRequest { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 400 for invalid sort, got %d: %s", resp.StatusCode, string(body)) - } - - t.Logf("✅ Rejected invalid sort value with 400") - }) - - t.Run("List with visibility filter", func(t *testing.T) { - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?visibility=public&limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with visibility filter: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var listResp struct { - Cursor string `json:"cursor"` - Communities []communities.Community `json:"communities"` - } - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - // Verify all communities have public visibility - for _, comm := range listResp.Communities { - if comm.Visibility != "public" { - t.Errorf("Expected all communities to have visibility=public, got %s for %s", - comm.Visibility, comm.DID) - } - } - - t.Logf("✅ Listed %d public communities", len(listResp.Communities)) - }) - - t.Run("List with default sort (no parameter)", func(t *testing.T) { - // Should default to sort=popular - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?limit=10", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with default sort: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - t.Logf("✅ List defaults to popular sort when no sort parameter provided") - }) - - t.Run("List with limit bounds validation", func(t *testing.T) { - // Test limit > 100 (should clamp to 100) - resp, err := http.Get(fmt.Sprintf("%s/xrpc/social.coves.community.list?limit=500", - httpServer.URL)) - if err != nil { - t.Fatalf("Failed to GET list with limit=500: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - t.Fatalf("Expected 200 (clamped limit), got %d: %s", resp.StatusCode, string(body)) - } - - var listResp struct { - Cursor string `json:"cursor"` - Communities []communities.Community `json:"communities"` - } - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if len(listResp.Communities) > 100 { - t.Errorf("Expected max 100 communities, got %d", len(listResp.Communities)) - } - - t.Logf("✅ Limit bounds validated (clamped to 100)") - }) - - t.Run("Subscribe via XRPC endpoint", func(t *testing.T) { - // Create a community to subscribe to - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - // Get initial subscriber count - initialCommunity, err := communityRepo.GetByDID(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get initial community state: %v", err) - } - initialSubscriberCount := initialCommunity.SubscriberCount - t.Logf("Initial subscriber count: %d", initialSubscriberCount) - - // Subscribe to the community with contentVisibility=5 (test max visibility) - // NOTE: HTTP API uses "community" field, but atProto record uses "subject" internally - subscribeReq := map[string]interface{}{ - "community": community.DID, - "contentVisibility": 5, // Test with max visibility - } - - reqBody, marshalErr := json.Marshal(subscribeReq) - if marshalErr != nil { - t.Fatalf("Failed to marshal subscribe request: %v", marshalErr) - } - - // POST subscribe request - t.Logf("📡 Client → POST /xrpc/social.coves.community.subscribe") - t.Logf(" Subscribing to community: %s", community.DID) - - req, err := http.NewRequest(http.MethodPost, - httpServer.URL+"/xrpc/social.coves.community.subscribe", - bytes.NewBuffer(reqBody)) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - // Use OAuth token for Coves API authentication - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST subscribe: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Logf("❌ XRPC Subscribe Failed") - t.Logf(" Status: %d", resp.StatusCode) - t.Logf(" Response: %s", string(body)) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var subscribeResp struct { - URI string `json:"uri"` - CID string `json:"cid"` - Existing bool `json:"existing"` - } - - if err := json.NewDecoder(resp.Body).Decode(&subscribeResp); err != nil { - t.Fatalf("Failed to decode subscribe response: %v", err) - } - - t.Logf("✅ XRPC subscribe response received:") - t.Logf(" URI: %s", subscribeResp.URI) - t.Logf(" CID: %s", subscribeResp.CID) - t.Logf(" Existing: %v", subscribeResp.Existing) - - // Verify the subscription was written to PDS (in user's repository) - t.Logf("🔍 Verifying subscription record on PDS...") - pdsURL := os.Getenv("PDS_URL") - if pdsURL == "" { - pdsURL = "http://localhost:3001" - } - - rkey := utils.ExtractRKeyFromURI(subscribeResp.URI) - // CRITICAL: Use correct collection name (record type, not XRPC endpoint) - collection := "social.coves.community.subscription" - - pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, instanceDID, collection, rkey)) - if pdsErr != nil { - t.Fatalf("Failed to fetch subscription record from PDS: %v", pdsErr) - } - defer func() { - if closeErr := pdsResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close PDS response: %v", closeErr) - } - }() - - if pdsResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(pdsResp.Body) - t.Fatalf("Subscription record not found on PDS: status %d, body: %s", pdsResp.StatusCode, string(body)) - } - - var pdsRecord struct { - Value map[string]interface{} `json:"value"` - } - if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil { - t.Fatalf("Failed to decode PDS record: %v", decodeErr) - } - - t.Logf("✅ Subscription record found on PDS:") - t.Logf(" Subject (community): %v", pdsRecord.Value["subject"]) - t.Logf(" ContentVisibility: %v", pdsRecord.Value["contentVisibility"]) - - // Verify the subject (community) DID matches - if pdsRecord.Value["subject"] != community.DID { - t.Errorf("Community DID mismatch: expected %s, got %v", community.DID, pdsRecord.Value["subject"]) - } - - // Verify contentVisibility was stored correctly - if cv, ok := pdsRecord.Value["contentVisibility"].(float64); ok { - if int(cv) != 5 { - t.Errorf("ContentVisibility mismatch: expected 5, got %v", cv) - } - } else { - t.Errorf("ContentVisibility not found or wrong type in PDS record") - } - - // CRITICAL: Simulate Jetstream consumer indexing the subscription - // This is the MISSING PIECE - we need to verify the firehose event gets indexed - t.Logf("🔄 Simulating Jetstream consumer indexing subscription...") - subEvent := jetstream.JetstreamEvent{ - Did: instanceDID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-sub-rev", - Operation: "create", - Collection: "social.coves.community.subscription", // CORRECT collection - RKey: rkey, - CID: subscribeResp.CID, - Record: map[string]interface{}{ - "$type": "social.coves.community.subscription", - "subject": community.DID, - "contentVisibility": float64(5), // JSON numbers are float64 - "createdAt": time.Now().Format(time.RFC3339), - }, - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &subEvent); handleErr != nil { - t.Fatalf("Failed to handle subscription event: %v", handleErr) - } - - // Verify subscription was indexed in AppView - t.Logf("🔍 Verifying subscription indexed in AppView...") - indexedSub, err := communityRepo.GetSubscription(ctx, instanceDID, community.DID) - if err != nil { - t.Fatalf("Subscription not indexed in AppView: %v", err) - } - - t.Logf("✅ Subscription indexed in AppView:") - t.Logf(" User: %s", indexedSub.UserDID) - t.Logf(" Community: %s", indexedSub.CommunityDID) - t.Logf(" ContentVisibility: %d", indexedSub.ContentVisibility) - t.Logf(" RecordURI: %s", indexedSub.RecordURI) - - // Verify contentVisibility was indexed correctly - if indexedSub.ContentVisibility != 5 { - t.Errorf("ContentVisibility not indexed correctly: expected 5, got %d", indexedSub.ContentVisibility) - } - - // Verify subscriber count was incremented - t.Logf("🔍 Verifying subscriber count incremented...") - updatedCommunity, err := communityRepo.GetByDID(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get updated community: %v", err) - } - - expectedCount := initialSubscriberCount + 1 - if updatedCommunity.SubscriberCount != expectedCount { - t.Errorf("Subscriber count not incremented: expected %d, got %d", - expectedCount, updatedCommunity.SubscriberCount) - } else { - t.Logf("✅ Subscriber count incremented: %d → %d", - initialSubscriberCount, updatedCommunity.SubscriberCount) - } - - t.Logf("✅ TRUE E2E SUBSCRIBE FLOW COMPLETE:") - t.Logf(" Client → XRPC Subscribe → PDS (user repo) → Firehose → Consumer → AppView ✓") - t.Logf(" ✓ Subscription written to PDS") - t.Logf(" ✓ Subscription indexed in AppView") - t.Logf(" ✓ ContentVisibility stored and indexed correctly (5)") - t.Logf(" ✓ Subscriber count incremented") - }) - - t.Run("Unsubscribe via XRPC endpoint", func(t *testing.T) { - // Create a community and subscribe to it first - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - // Get initial subscriber count - initialCommunity, err := communityRepo.GetByDID(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get initial community state: %v", err) - } - initialSubscriberCount := initialCommunity.SubscriberCount - t.Logf("Initial subscriber count: %d", initialSubscriberCount) - - // Subscribe first (using instance access token for instance user, with contentVisibility=3) - // Create a session for the instance user - parsedDID, _ := syntax.ParseDID(instanceDID) - instanceSession := &oauthlib.ClientSessionData{ - AccountDID: parsedDID, - SessionID: "test-session-e2e", - HostURL: pdsURL, - AccessToken: accessToken, - } - subscription, err := communityService.SubscribeToCommunity(ctx, instanceSession, community.DID, 3) - if err != nil { - t.Fatalf("Failed to subscribe: %v", err) - } - - // Index the subscription in AppView (simulate firehose event) - rkey := utils.ExtractRKeyFromURI(subscription.RecordURI) - subEvent := jetstream.JetstreamEvent{ - Did: instanceDID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-sub-rev", - Operation: "create", - Collection: "social.coves.community.subscription", // CORRECT collection - RKey: rkey, - CID: subscription.RecordCID, - Record: map[string]interface{}{ - "$type": "social.coves.community.subscription", - "subject": community.DID, - "contentVisibility": float64(3), - "createdAt": time.Now().Format(time.RFC3339), - }, - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &subEvent); handleErr != nil { - t.Fatalf("Failed to handle subscription event: %v", handleErr) - } - - // Verify subscription was indexed - _, err = communityRepo.GetSubscription(ctx, instanceDID, community.DID) - if err != nil { - t.Fatalf("Subscription not indexed: %v", err) - } - - // Verify subscriber count incremented - midCommunity, err := communityRepo.GetByDID(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get community after subscribe: %v", err) - } - if midCommunity.SubscriberCount != initialSubscriberCount+1 { - t.Errorf("Subscriber count not incremented after subscribe: expected %d, got %d", - initialSubscriberCount+1, midCommunity.SubscriberCount) - } - - t.Logf("📝 Subscription created and indexed: %s", subscription.RecordURI) - - // Now unsubscribe via XRPC endpoint - unsubscribeReq := map[string]interface{}{ - "community": community.DID, - } - - reqBody, marshalErr := json.Marshal(unsubscribeReq) - if marshalErr != nil { - t.Fatalf("Failed to marshal unsubscribe request: %v", marshalErr) - } - - // POST unsubscribe request - t.Logf("📡 Client → POST /xrpc/social.coves.community.unsubscribe") - t.Logf(" Unsubscribing from community: %s", community.DID) - - req, err := http.NewRequest(http.MethodPost, - httpServer.URL+"/xrpc/social.coves.community.unsubscribe", - bytes.NewBuffer(reqBody)) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - // Use OAuth token for Coves API authentication - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST unsubscribe: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Logf("❌ XRPC Unsubscribe Failed") - t.Logf(" Status: %d", resp.StatusCode) - t.Logf(" Response: %s", string(body)) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var unsubscribeResp struct { - Success bool `json:"success"` - } - - if err := json.NewDecoder(resp.Body).Decode(&unsubscribeResp); err != nil { - t.Fatalf("Failed to decode unsubscribe response: %v", err) - } - - t.Logf("✅ XRPC unsubscribe response received:") - t.Logf(" Success: %v", unsubscribeResp.Success) - - if !unsubscribeResp.Success { - t.Errorf("Expected success: true, got: %v", unsubscribeResp.Success) - } - - // Verify the subscription record was deleted from PDS - t.Logf("🔍 Verifying subscription record deleted from PDS...") - pdsURL := os.Getenv("PDS_URL") - if pdsURL == "" { - pdsURL = "http://localhost:3001" - } - - // CRITICAL: Use correct collection name (record type, not XRPC endpoint) - collection := "social.coves.community.subscription" - pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, instanceDID, collection, rkey)) - if pdsErr != nil { - t.Fatalf("Failed to query PDS: %v", pdsErr) - } - defer func() { - if closeErr := pdsResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close PDS response: %v", closeErr) - } - }() - - // Should return 404 since record was deleted - if pdsResp.StatusCode == http.StatusOK { - t.Errorf("❌ Subscription record still exists on PDS (expected 404, got 200)") - } else { - t.Logf("✅ Subscription record successfully deleted from PDS (status: %d)", pdsResp.StatusCode) - } - - // CRITICAL: Simulate Jetstream consumer indexing the DELETE event - t.Logf("🔄 Simulating Jetstream consumer indexing DELETE event...") - deleteEvent := jetstream.JetstreamEvent{ - Did: instanceDID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-unsub-rev", - Operation: "delete", - Collection: "social.coves.community.subscription", - RKey: rkey, - CID: "", // No CID on deletes - Record: nil, // No record data on deletes - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &deleteEvent); handleErr != nil { - t.Fatalf("Failed to handle delete event: %v", handleErr) - } - - // Verify subscription was removed from AppView - t.Logf("🔍 Verifying subscription removed from AppView...") - _, err = communityRepo.GetSubscription(ctx, instanceDID, community.DID) - if err == nil { - t.Errorf("❌ Subscription still exists in AppView (should be deleted)") - } else if !communities.IsNotFound(err) { - t.Fatalf("Unexpected error querying subscription: %v", err) - } else { - t.Logf("✅ Subscription removed from AppView") - } - - // Verify subscriber count was decremented - t.Logf("🔍 Verifying subscriber count decremented...") - finalCommunity, err := communityRepo.GetByDID(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get final community state: %v", err) - } - - if finalCommunity.SubscriberCount != initialSubscriberCount { - t.Errorf("Subscriber count not decremented: expected %d, got %d", - initialSubscriberCount, finalCommunity.SubscriberCount) - } else { - t.Logf("✅ Subscriber count decremented: %d → %d", - initialSubscriberCount+1, finalCommunity.SubscriberCount) - } - - t.Logf("✅ TRUE E2E UNSUBSCRIBE FLOW COMPLETE:") - t.Logf(" Client → XRPC Unsubscribe → PDS Delete → Firehose → Consumer → AppView ✓") - t.Logf(" ✓ Subscription deleted from PDS") - t.Logf(" ✓ Subscription removed from AppView") - t.Logf(" ✓ Subscriber count decremented") - }) - - t.Run("Block via XRPC endpoint", func(t *testing.T) { - // Create a community to block - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - t.Logf("🚫 Blocking community via XRPC endpoint...") - blockReq := map[string]interface{}{ - "community": community.DID, - } - - blockJSON, err := json.Marshal(blockReq) - if err != nil { - t.Fatalf("Failed to marshal block request: %v", err) - } - - req, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(blockJSON)) - if err != nil { - t.Fatalf("Failed to create block request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST block: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Logf("❌ XRPC Block Failed") - t.Logf(" Status: %d", resp.StatusCode) - t.Logf(" Response: %s", string(body)) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var blockResp struct { - Block struct { - RecordURI string `json:"recordUri"` - RecordCID string `json:"recordCid"` - } `json:"block"` - } - - if err := json.NewDecoder(resp.Body).Decode(&blockResp); err != nil { - t.Fatalf("Failed to decode block response: %v", err) - } - - t.Logf("✅ XRPC block response received:") - t.Logf(" RecordURI: %s", blockResp.Block.RecordURI) - t.Logf(" RecordCID: %s", blockResp.Block.RecordCID) - - // Extract rkey from URI for verification - rkey := "" - if uriParts := strings.Split(blockResp.Block.RecordURI, "/"); len(uriParts) >= 4 { - rkey = uriParts[len(uriParts)-1] - } - - // Verify the block record exists on PDS - t.Logf("🔍 Verifying block record exists on PDS...") - collection := "social.coves.community.block" - pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, instanceDID, collection, rkey)) - if pdsErr != nil { - t.Fatalf("Failed to query PDS: %v", pdsErr) - } - defer func() { - if closeErr := pdsResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close PDS response: %v", closeErr) - } - }() - - if pdsResp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(pdsResp.Body) - if readErr != nil { - t.Fatalf("Block record not found on PDS (status: %d, failed to read body: %v)", pdsResp.StatusCode, readErr) - } - t.Fatalf("Block record not found on PDS (status: %d): %s", pdsResp.StatusCode, string(body)) - } - t.Logf("✅ Block record exists on PDS") - - // CRITICAL: Simulate Jetstream consumer indexing the block - t.Logf("🔄 Simulating Jetstream consumer indexing block event...") - blockEvent := jetstream.JetstreamEvent{ - Did: instanceDID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-block-rev", - Operation: "create", - Collection: "social.coves.community.block", - RKey: rkey, - CID: blockResp.Block.RecordCID, - Record: map[string]interface{}{ - "subject": community.DID, - "createdAt": time.Now().Format(time.RFC3339), - }, - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &blockEvent); handleErr != nil { - t.Fatalf("Failed to handle block event: %v", handleErr) - } - - // Verify block was indexed in AppView - t.Logf("🔍 Verifying block indexed in AppView...") - block, err := communityRepo.GetBlock(ctx, instanceDID, community.DID) - if err != nil { - t.Fatalf("Failed to get block from AppView: %v", err) - } - if block.RecordURI != blockResp.Block.RecordURI { - t.Errorf("RecordURI mismatch: expected %s, got %s", blockResp.Block.RecordURI, block.RecordURI) - } - - t.Logf("✅ TRUE E2E BLOCK FLOW COMPLETE:") - t.Logf(" Client → XRPC Block → PDS Create → Firehose → Consumer → AppView ✓") - t.Logf(" ✓ Block record created on PDS") - t.Logf(" ✓ Block indexed in AppView") - }) - - t.Run("Unblock via XRPC endpoint", func(t *testing.T) { - // Create a community and block it first - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - // Block the community - t.Logf("🚫 Blocking community first...") - blockReq := map[string]interface{}{ - "community": community.DID, - } - blockJSON, err := json.Marshal(blockReq) - if err != nil { - t.Fatalf("Failed to marshal block request: %v", err) - } - - blockHttpReq, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(blockJSON)) - if err != nil { - t.Fatalf("Failed to create block request: %v", err) - } - blockHttpReq.Header.Set("Content-Type", "application/json") - blockHttpReq.Header.Set("Authorization", "Bearer "+token) - - blockResp, err := http.DefaultClient.Do(blockHttpReq) - if err != nil { - t.Fatalf("Failed to POST block: %v", err) - } - - var blockRespData struct { - Block struct { - RecordURI string `json:"recordUri"` - } `json:"block"` - } - if err := json.NewDecoder(blockResp.Body).Decode(&blockRespData); err != nil { - func() { _ = blockResp.Body.Close() }() - t.Fatalf("Failed to decode block response: %v", err) - } - func() { _ = blockResp.Body.Close() }() - - rkey := "" - if uriParts := strings.Split(blockRespData.Block.RecordURI, "/"); len(uriParts) >= 4 { - rkey = uriParts[len(uriParts)-1] - } - - // Index the block via consumer - blockEvent := jetstream.JetstreamEvent{ - Did: instanceDID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-block-rev", - Operation: "create", - Collection: "social.coves.community.block", - RKey: rkey, - CID: "test-block-cid", - Record: map[string]interface{}{ - "subject": community.DID, - "createdAt": time.Now().Format(time.RFC3339), - }, - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &blockEvent); handleErr != nil { - t.Fatalf("Failed to handle block event: %v", handleErr) - } - - // Now unblock the community - t.Logf("✅ Unblocking community via XRPC endpoint...") - unblockReq := map[string]interface{}{ - "community": community.DID, - } - - unblockJSON, err := json.Marshal(unblockReq) - if err != nil { - t.Fatalf("Failed to marshal unblock request: %v", err) - } - - req, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(unblockJSON)) - if err != nil { - t.Fatalf("Failed to create unblock request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST unblock: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Logf("❌ XRPC Unblock Failed") - t.Logf(" Status: %d", resp.StatusCode) - t.Logf(" Response: %s", string(body)) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var unblockResp struct { - Success bool `json:"success"` - } - - if err := json.NewDecoder(resp.Body).Decode(&unblockResp); err != nil { - t.Fatalf("Failed to decode unblock response: %v", err) - } - - if !unblockResp.Success { - t.Errorf("Expected success: true, got: %v", unblockResp.Success) - } - - // Verify the block record was deleted from PDS - t.Logf("🔍 Verifying block record deleted from PDS...") - collection := "social.coves.community.block" - pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, instanceDID, collection, rkey)) - if pdsErr != nil { - t.Fatalf("Failed to query PDS: %v", pdsErr) - } - defer func() { - if closeErr := pdsResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close PDS response: %v", closeErr) - } - }() - - if pdsResp.StatusCode == http.StatusOK { - t.Errorf("❌ Block record still exists on PDS (expected 404, got 200)") - } else { - t.Logf("✅ Block record successfully deleted from PDS (status: %d)", pdsResp.StatusCode) - } - - // CRITICAL: Simulate Jetstream consumer indexing the DELETE event - t.Logf("🔄 Simulating Jetstream consumer indexing DELETE event...") - deleteEvent := jetstream.JetstreamEvent{ - Did: instanceDID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-unblock-rev", - Operation: "delete", - Collection: "social.coves.community.block", - RKey: rkey, - CID: "", - Record: nil, - }, - } - if handleErr := consumer.HandleEvent(context.Background(), &deleteEvent); handleErr != nil { - t.Fatalf("Failed to handle delete event: %v", handleErr) - } - - // Verify block was removed from AppView - t.Logf("🔍 Verifying block removed from AppView...") - _, err = communityRepo.GetBlock(ctx, instanceDID, community.DID) - if err == nil { - t.Errorf("❌ Block still exists in AppView (should be deleted)") - } else if !communities.IsNotFound(err) { - t.Fatalf("Unexpected error querying block: %v", err) - } else { - t.Logf("✅ Block removed from AppView") - } - - t.Logf("✅ TRUE E2E UNBLOCK FLOW COMPLETE:") - t.Logf(" Client → XRPC Unblock → PDS Delete → Firehose → Consumer → AppView ✓") - t.Logf(" ✓ Block deleted from PDS") - t.Logf(" ✓ Block removed from AppView") - }) - - t.Run("Block fails without authentication", func(t *testing.T) { - // Create a community to attempt blocking - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - t.Logf("🔒 Attempting to block community without auth token...") - blockReq := map[string]interface{}{ - "community": community.DID, - } - - blockJSON, err := json.Marshal(blockReq) - if err != nil { - t.Fatalf("Failed to marshal block request: %v", err) - } - - req, err := http.NewRequest("POST", httpServer.URL+"/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(blockJSON)) - if err != nil { - t.Fatalf("Failed to create block request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - // NO Authorization header - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST block: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - // Should fail with 401 Unauthorized - if resp.StatusCode != http.StatusUnauthorized { - body, _ := io.ReadAll(resp.Body) - t.Errorf("Expected 401 Unauthorized, got %d: %s", resp.StatusCode, string(body)) - } else { - t.Logf("✅ Block correctly rejected without authentication (401)") - } - }) - - t.Run("Update via XRPC endpoint", func(t *testing.T) { - // Create a community first (via service, so it's indexed) - community := createAndIndexCommunity(t, communityService, consumer, instanceDID, pdsURL) - - // Update the community - newDisplayName := "Updated E2E Test Community" - newDescription := "This community has been updated" - newVisibility := "unlisted" - - // NOTE: updatedByDid is derived from JWT token, not provided in request - updateReq := map[string]interface{}{ - "communityDid": community.DID, - "displayName": newDisplayName, - "description": newDescription, - "visibility": newVisibility, - } - - reqBody, marshalErr := json.Marshal(updateReq) - if marshalErr != nil { - t.Fatalf("Failed to marshal update request: %v", marshalErr) - } - - // POST update request with JWT authentication - t.Logf("📡 Client → POST /xrpc/social.coves.community.update") - t.Logf(" Updating community: %s", community.DID) - - req, err := http.NewRequest(http.MethodPost, - httpServer.URL+"/xrpc/social.coves.community.update", - bytes.NewBuffer(reqBody)) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - req.Header.Set("Content-Type", "application/json") - // Use OAuth token for Coves API authentication - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Failed to POST update: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - t.Fatalf("Expected 200, got %d (failed to read body: %v)", resp.StatusCode, readErr) - } - t.Logf("❌ XRPC Update Failed") - t.Logf(" Status: %d", resp.StatusCode) - t.Logf(" Response: %s", string(body)) - t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) - } - - var updateResp struct { - URI string `json:"uri"` - CID string `json:"cid"` - DID string `json:"did"` - Handle string `json:"handle"` - } - - if err := json.NewDecoder(resp.Body).Decode(&updateResp); err != nil { - t.Fatalf("Failed to decode update response: %v", err) - } - - t.Logf("✅ XRPC update response received:") - t.Logf(" DID: %s", updateResp.DID) - t.Logf(" URI: %s", updateResp.URI) - t.Logf(" CID: %s (changed after update)", updateResp.CID) - - // Verify the CID changed (update creates a new version) - if updateResp.CID == community.RecordCID { - t.Logf("⚠️ Warning: CID did not change after update (expected for a new version)") - } - - // Simulate Jetstream consumer picking up the update event - t.Logf("🔄 Simulating Jetstream consumer indexing update...") - rkey := utils.ExtractRKeyFromURI(updateResp.URI) - - // Fetch updated record from PDS - pdsURL := os.Getenv("PDS_URL") - if pdsURL == "" { - pdsURL = "http://localhost:3001" - } - - collection := "social.coves.community.profile" - pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, community.DID, collection, rkey)) - if pdsErr != nil { - t.Fatalf("Failed to fetch updated PDS record: %v", pdsErr) - } - defer func() { - if closeErr := pdsResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close PDS response: %v", closeErr) - } - }() - - var pdsRecord struct { - Value map[string]interface{} `json:"value"` - CID string `json:"cid"` - } - if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil { - t.Fatalf("Failed to decode PDS record: %v", decodeErr) - } - - // Create update event for consumer - updateEvent := jetstream.JetstreamEvent{ - Did: community.DID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test-update-rev", - Operation: "update", - Collection: collection, - RKey: rkey, - CID: pdsRecord.CID, - Record: pdsRecord.Value, - }, - } - - if handleErr := consumer.HandleEvent(context.Background(), &updateEvent); handleErr != nil { - t.Fatalf("Failed to handle update event: %v", handleErr) - } - - // Verify update was indexed in AppView - t.Logf("🔍 Querying AppView to verify update was indexed...") - updated, err := communityService.GetCommunity(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get updated community: %v", err) - } - - t.Logf("✅ Update indexed in AppView:") - t.Logf(" DisplayName: %s (was: %s)", updated.DisplayName, community.DisplayName) - t.Logf(" Description: %s", updated.Description) - t.Logf(" Visibility: %s (was: %s)", updated.Visibility, community.Visibility) - - // Verify the updates were applied - if updated.DisplayName != newDisplayName { - t.Errorf("DisplayName not updated: expected %s, got %s", newDisplayName, updated.DisplayName) - } - if updated.Description != newDescription { - t.Errorf("Description not updated: expected %s, got %s", newDescription, updated.Description) - } - if updated.Visibility != newVisibility { - t.Errorf("Visibility not updated: expected %s, got %s", newVisibility, updated.Visibility) - } - - t.Logf("✅ TRUE E2E UPDATE FLOW COMPLETE:") - t.Logf(" Client → XRPC Update → PDS → Firehose → AppView ✓") - }) - - t.Logf("\n✅ Part 3 Complete: All XRPC HTTP endpoints working ✓") - }) - - divider := strings.Repeat("=", 80) - t.Logf("\n%s", divider) - t.Logf("✅ TRUE END-TO-END TEST COMPLETE - V2 COMMUNITIES ARCHITECTURE") - t.Logf("%s", divider) - t.Logf("\n🎯 Complete Flow Tested:") - t.Logf(" 1. HTTP Request → Service Layer") - t.Logf(" 2. Service → PDS Account Creation (com.atproto.server.createAccount)") - t.Logf(" 3. Service → PDS Record Write (at://community_did/profile/self)") - t.Logf(" 4. PDS → Jetstream Firehose (REAL WebSocket subscription!)") - t.Logf(" 5. Jetstream → Consumer Event Handler") - t.Logf(" 6. Consumer → AppView PostgreSQL Database") - t.Logf(" 7. AppView DB → XRPC HTTP Endpoints") - t.Logf(" 8. XRPC → Client Response") - t.Logf("\n✅ V2 Architecture Verified:") - t.Logf(" ✓ Community owns its own PDS account") - t.Logf(" ✓ Community owns its own repository (at://community_did/...)") - t.Logf(" ✓ PDS manages signing keypair (we only store credentials)") - t.Logf(" ✓ Real Jetstream firehose event consumption") - t.Logf(" ✓ True portability (community can migrate instances)") - t.Logf(" ✓ Full atProto compliance") - t.Logf("\n%s", divider) - t.Logf("🚀 V2 Communities: Production Ready!") - t.Logf("%s\n", divider) -} - -// Helper: create and index a community (simulates consumer indexing for fast test setup) -// NOTE: This simulates the firehose event for speed. For TRUE E2E testing with real -// Jetstream WebSocket subscription, see "Part 2: Real Jetstream Firehose Consumption" above. -func createAndIndexCommunity(t *testing.T, service communities.Service, consumer *jetstream.CommunityEventConsumer, instanceDID, pdsURL string) *communities.Community { - // uniqueTestID() (Unix seconds + atomic counter) avoids handle collisions across - // reruns ("handle already taken") while keeping the provisioned local label ≤18 chars. - req := communities.CreateCommunityRequest{ - Name: fmt.Sprintf("tc%s", uniqueTestID()), - DisplayName: "Test Community", - Description: "Test", - Visibility: "public", - CreatedByDID: instanceDID, - HostedByDID: instanceDID, - AllowExternalDiscovery: true, - } - - community, err := service.CreateCommunity(context.Background(), req) - if err != nil { - t.Fatalf("Failed to create: %v", err) - } - - // Fetch from PDS to get full record - // V2: Record lives in community's own repository (at://community.DID/...) - collection := "social.coves.community.profile" - rkey := utils.ExtractRKeyFromURI(community.RecordURI) - - pdsResp, pdsErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, community.DID, collection, rkey)) - if pdsErr != nil { - t.Fatalf("Failed to fetch PDS record: %v", pdsErr) - } - defer func() { - if closeErr := pdsResp.Body.Close(); closeErr != nil { - t.Logf("Failed to close PDS response: %v", closeErr) - } - }() - - var pdsRecord struct { - Value map[string]interface{} `json:"value"` - CID string `json:"cid"` - } - if decodeErr := json.NewDecoder(pdsResp.Body).Decode(&pdsRecord); decodeErr != nil { - t.Fatalf("Failed to decode PDS record: %v", decodeErr) - } - - // Simulate firehose event for fast indexing - // V2: Event comes from community's DID (community owns the repo) - // NOTE: This bypasses real Jetstream WebSocket for speed. Real firehose testing - // happens in "Part 2: Real Jetstream Firehose Consumption" above. - event := jetstream.JetstreamEvent{ - Did: community.DID, - TimeUS: time.Now().UnixMicro(), - Kind: "commit", - Commit: &jetstream.CommitEvent{ - Rev: "test", - Operation: "create", - Collection: collection, - RKey: rkey, - CID: pdsRecord.CID, - Record: pdsRecord.Value, - }, - } - - if handleErr := consumer.HandleEvent(context.Background(), &event); handleErr != nil { - t.Logf("Warning: failed to handle event: %v", handleErr) - } - - return community -} - -// queryPDSAccount queries the PDS to verify an account exists -// Returns the account's DID and handle if found -func queryPDSAccount(pdsURL, handle string) (string, string, error) { - // Use com.atproto.identity.resolveHandle to verify account exists - resp, err := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", pdsURL, handle)) - if err != nil { - return "", "", fmt.Errorf("failed to query PDS: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return "", "", fmt.Errorf("account not found (status %d, failed to read body: %w)", resp.StatusCode, readErr) - } - return "", "", fmt.Errorf("account not found (status %d): %s", resp.StatusCode, string(body)) - } - - var result struct { - DID string `json:"did"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", "", fmt.Errorf("failed to decode response: %w", err) - } - - return result.DID, handle, nil -} - -// subscribeToJetstream subscribes to real Jetstream firehose and processes events -// This enables TRUE E2E testing: PDS → Jetstream → Consumer → AppView -func subscribeToJetstream( - ctx context.Context, - jetstreamURL string, - targetDID string, - consumer *jetstream.CommunityEventConsumer, - eventChan chan<- *jetstream.JetstreamEvent, - errorChan chan<- error, - done <-chan bool, -) error { - // Import needed for websocket - // Note: We'll use the gorilla websocket library - conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil) - if err != nil { - return fmt.Errorf("failed to connect to Jetstream: %w", err) - } - defer func() { _ = conn.Close() }() - - // ONE deadline for the whole subscription, not one per read: the - // budget is what the caller is willing to wait in total, and a - // per-read deadline would let a busy stream extend it indefinitely. - readDeadline := time.Now().Add(jetstreamReadBudget) - - // Read messages until we find our event or receive done signal - for { - select { - case <-done: - return nil - case <-ctx.Done(): - return ctx.Err() - default: - // Set read deadline to avoid blocking forever - if err := conn.SetReadDeadline(readDeadline); err != nil { - return fmt.Errorf("failed to set read deadline: %w", err) - } - - var event jetstream.JetstreamEvent - err := conn.ReadJSON(&event) - if err != nil { - // Check if it's a timeout (expected) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - return fmt.Errorf("Jetstream closed the subscription before the event arrived") - } - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - // The deadline is the whole budget, so its expiry is the answer: - // no matching event arrived. Reading on would be reading a - // connection gorilla has already marked failed. - return fmt.Errorf("no matching event within %s", jetstreamReadBudget) - } - // For other errors, don't retry reading from a broken connection - return fmt.Errorf("failed to read Jetstream message: %w", err) - } - - // Check if this is the event we're looking for - if event.Did == targetDID && event.Kind == "commit" { - // Process the event through the consumer - if err := consumer.HandleEvent(ctx, &event); err != nil { - return fmt.Errorf("failed to process event: %w", err) - } - - // Send to channel so test can verify - select { - case eventChan <- &event: - return nil - case <-time.After(1 * time.Second): - return fmt.Errorf("timeout sending event to channel") - } - } - } - } -} diff --git a/tests/integration/community_update_e2e_test.go b/tests/integration/community_update_e2e_test.go deleted file mode 100644 index bf59c5c..0000000 --- a/tests/integration/community_update_e2e_test.go +++ /dev/null @@ -1,381 +0,0 @@ -//go:build integration - -package integration - -// SERIAL BY DESIGN — do not add t.Parallel() to this file. -// -// Its tests drive the Jetstream firehose through the hand-rolled -// subscribeToJetstream* helpers below rather than testkit's cursor-gated -// subscriber. Those helpers subscribe to one shared stream and match on the -// first event of a collection, so a concurrent test writing the same -// collection is delivered to them too and either steals the match or trips -// their timeout. Per-test database clones do not isolate a shared websocket. -// -// docs/TEST_ARCHITECTURE.md §3.3 ("Parallelism is earned, not assumed"). - -import ( - "Coves/internal/atproto/identity" - "Coves/internal/atproto/jetstream" - "Coves/internal/core/blobs" - "Coves/internal/core/communities" - "Coves/internal/db/postgres" - "Coves/tests/testkit" - "context" - "errors" - "fmt" - "io" - "net" - "net/http" - "os" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" -) - -// TestCommunityUpdateE2E_WithJetstream tests the FULL community update flow with REAL Jetstream -// Flow: Service.UpdateCommunity() → PDS putRecord → REAL Jetstream Firehose → Consumer → AppView DB -// -// This is a TRUE E2E test - no simulated Jetstream events! -func TestCommunityUpdateE2E_WithJetstream(t *testing.T) { - db := testkit.DB(t) - - // Check if PDS is running - pdsURL := os.Getenv("PDS_URL") - if pdsURL == "" { - pdsURL = "http://localhost:3001" - } - - healthResp, err := http.Get(pdsURL + "/xrpc/_health") - if err != nil { - t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) - } - _ = healthResp.Body.Close() - - // Check if Jetstream is running - pdsHostname := strings.TrimPrefix(pdsURL, "http://") - pdsHostname = strings.TrimPrefix(pdsHostname, "https://") - pdsHostname = strings.Split(pdsHostname, ":")[0] - jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.profile", pdsHostname) - - testConn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil) - if err != nil { - t.Skipf("Jetstream not running at %s: %v. Run 'docker-compose --profile jetstream up' to start.", jetstreamURL, err) - } - _ = testConn.Close() - - ctx := context.Background() - instanceDID := "did:web:coves.social" - - // Setup identity resolver with local PLC - plcURL := os.Getenv("PLC_DIRECTORY_URL") - if plcURL == "" { - plcURL = "http://localhost:3002" // Local PLC directory - } - identityConfig := identity.DefaultConfig() - identityConfig.PLCURL = plcURL - identityResolver := identity.NewResolver(db, identityConfig) - - // Setup services - communityRepo := postgres.NewCommunityRepository(db) - provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) - blobService := blobs.NewBlobService(pdsURL) - communityService := communities.NewCommunityServiceWithPDSFactory( - communityRepo, - pdsURL, - instanceDID, - "coves.social", - provisioner, - nil, - blobService, - ) - - consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver) - - t.Run("update community with real Jetstream indexing", func(t *testing.T) { - // First, create a community - uniqueName := fmt.Sprintf("upd%s", uniqueTestID()) - creatorDID := "did:plc:jetstream-update-test" - - t.Logf("\n📝 Creating community on PDS...") - community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ - Name: uniqueName, - DisplayName: "Original Display Name", - Description: "Original description before update", - Visibility: "public", - CreatedByDID: creatorDID, - HostedByDID: instanceDID, - AllowExternalDiscovery: true, - }) - if err != nil { - t.Fatalf("Failed to create community: %v", err) - } - - t.Logf("✅ Community created on PDS:") - t.Logf(" DID: %s", community.DID) - t.Logf(" RecordCID: %s", community.RecordCID) - - // Verify community is indexed (the service indexes it synchronously on create) - t.Logf("\n🔄 Checking community is indexed...") - indexed, err := communityService.GetCommunity(ctx, community.DID) - if err != nil { - t.Fatalf("Community not indexed: %v", err) - } - t.Logf("✅ Community indexed in AppView: %s", indexed.DisplayName) - - // Now update the community - t.Logf("\n📝 Updating community via service...") - - // Start Jetstream subscription for update event BEFORE calling update - updateEventChan := make(chan *jetstream.JetstreamEvent, 10) - updateErrorChan := make(chan error, 1) - updateDone := make(chan bool) - - go func() { - subscribeErr := subscribeToJetstreamForCommunityEvent(ctx, jetstreamURL, community.DID, "update", consumer, updateEventChan, updateDone) - if subscribeErr != nil { - updateErrorChan <- subscribeErr - } - }() - - // Give Jetstream a moment to connect - time.Sleep(500 * time.Millisecond) - - // Perform the update - newDisplayName := "Updated via TRUE E2E Test!" - newDescription := "This description was updated and indexed via real Jetstream firehose" - newVisibility := "unlisted" - - updated, err := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ - CommunityDID: community.DID, - UpdatedByDID: creatorDID, - DisplayName: &newDisplayName, - Description: &newDescription, - Visibility: &newVisibility, - AllowExternalDiscovery: nil, - }) - if err != nil { - t.Fatalf("Failed to update community: %v", err) - } - - t.Logf("✅ Community update written to PDS:") - t.Logf(" New RecordCID: %s (was: %s)", updated.RecordCID, community.RecordCID) - - // Wait for update event from real Jetstream - t.Logf("\n⏳ Waiting for update event from Jetstream (max 30 seconds)...") - - select { - case event := <-updateEventChan: - t.Logf("✅ Received REAL update event from Jetstream!") - t.Logf(" Event DID: %s", event.Did) - t.Logf(" Collection: %s", event.Commit.Collection) - t.Logf(" Operation: %s", event.Commit.Operation) - t.Logf(" RKey: %s", event.Commit.RKey) - - // Verify operation type - if event.Commit.Operation != "update" { - t.Errorf("Expected operation 'update', got '%s'", event.Commit.Operation) - } - - // Verify the update was indexed in AppView - t.Logf("\n🔍 Verifying update indexed in AppView...") - indexedUpdated, err := communityService.GetCommunity(ctx, community.DID) - if err != nil { - t.Fatalf("Failed to get updated community: %v", err) - } - - t.Logf("✅ Update indexed in AppView:") - t.Logf(" DisplayName: %s", indexedUpdated.DisplayName) - t.Logf(" Description: %s", indexedUpdated.Description) - t.Logf(" Visibility: %s", indexedUpdated.Visibility) - - // Verify the changes - if indexedUpdated.DisplayName != newDisplayName { - t.Errorf("Expected display name '%s', got '%s'", newDisplayName, indexedUpdated.DisplayName) - } - if indexedUpdated.Description != newDescription { - t.Errorf("Expected description '%s', got '%s'", newDescription, indexedUpdated.Description) - } - if indexedUpdated.Visibility != newVisibility { - t.Errorf("Expected visibility '%s', got '%s'", newVisibility, indexedUpdated.Visibility) - } - - close(updateDone) - - case err := <-updateErrorChan: - t.Fatalf("Jetstream error: %v", err) - - case <-time.After(30 * time.Second): - t.Fatalf("Timeout: No update event received from Jetstream within 30 seconds") - } - - t.Logf("\n✅ TRUE E2E COMMUNITY UPDATE FLOW COMPLETE:") - t.Logf(" Service → PDS putRecord → Jetstream Firehose → Consumer → AppView ✓") - }) - - t.Run("multiple updates with real Jetstream", func(t *testing.T) { - // This tests that consecutive updates all flow through Jetstream correctly - uniqueName := fmt.Sprintf("mlt%s", uniqueTestID()) - creatorDID := "did:plc:multi-update-test" - - t.Logf("\n📝 Creating community for multi-update test...") - community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ - Name: uniqueName, - DisplayName: "Multi-Update Test", - Description: "Testing multiple updates", - Visibility: "public", - CreatedByDID: creatorDID, - HostedByDID: instanceDID, - AllowExternalDiscovery: true, - }) - if err != nil { - t.Fatalf("Failed to create community: %v", err) - } - - // Verify create is indexed (service indexes synchronously on create) - indexed, err := communityService.GetCommunity(ctx, community.DID) - if err != nil { - t.Fatalf("Community not indexed after create: %v", err) - } - t.Logf("✅ Create indexed: %s", indexed.DisplayName) - - // Perform 3 consecutive updates - for i := 1; i <= 3; i++ { - t.Logf("\n📝 Update %d of 3...", i) - - updateEventChan := make(chan *jetstream.JetstreamEvent, 10) - updateErrorChan := make(chan error, 1) - updateDone := make(chan bool) - - go func() { - subscribeErr := subscribeToJetstreamForCommunityEvent(ctx, jetstreamURL, community.DID, "update", consumer, updateEventChan, updateDone) - if subscribeErr != nil { - updateErrorChan <- subscribeErr - } - }() - - time.Sleep(300 * time.Millisecond) - - newDesc := fmt.Sprintf("Update #%d at %s", i, time.Now().Format(time.RFC3339)) - _, err := communityService.UpdateCommunity(ctx, communities.UpdateCommunityRequest{ - CommunityDID: community.DID, - UpdatedByDID: creatorDID, - Description: &newDesc, - }) - if err != nil { - t.Fatalf("Update %d failed: %v", i, err) - } - - select { - case event := <-updateEventChan: - if event.Commit.Operation != "update" { - t.Errorf("Expected update operation, got %s", event.Commit.Operation) - } - t.Logf("✅ Update %d received via Jetstream", i) - case err := <-updateErrorChan: - t.Fatalf("Jetstream error on update %d: %v", i, err) - case <-time.After(30 * time.Second): - t.Fatalf("Timeout on update %d", i) - } - close(updateDone) - - // Verify in AppView - indexed, getErr := communityService.GetCommunity(ctx, community.DID) - if getErr != nil { - t.Fatalf("Update %d: failed to get community: %v", i, getErr) - } - if indexed.Description != newDesc { - t.Errorf("Update %d: expected description '%s', got '%s'", i, newDesc, indexed.Description) - } - } - - t.Logf("\n✅ MULTIPLE UPDATES TEST COMPLETE:") - t.Logf(" 3 consecutive updates all indexed via real Jetstream ✓") - }) -} - -// subscribeToJetstreamForCommunityEvent subscribes to real Jetstream for specific community events -func subscribeToJetstreamForCommunityEvent( - ctx context.Context, - jetstreamURL string, - targetDID string, - operation string, // "create", "update", or "delete" - consumer *jetstream.CommunityEventConsumer, - eventChan chan<- *jetstream.JetstreamEvent, - done <-chan bool, -) error { - conn, _, err := websocket.DefaultDialer.Dial(jetstreamURL, nil) - if err != nil { - return fmt.Errorf("failed to connect to Jetstream: %w", err) - } - defer func() { _ = conn.Close() }() - - // ONE deadline for the whole subscription, not one per read: the - // budget is what the caller is willing to wait in total, and a - // per-read deadline would let a busy stream extend it indefinitely. - readDeadline := time.Now().Add(jetstreamReadBudget) - - // The gorilla/websocket library panics after 1000 repeated reads on a failed connection - - for { - select { - case <-done: - return nil - case <-ctx.Done(): - return ctx.Err() - default: - if err := conn.SetReadDeadline(readDeadline); err != nil { - return fmt.Errorf("failed to set read deadline: %w", err) - } - - var event jetstream.JetstreamEvent - err := conn.ReadJSON(&event) - if err != nil { - // Check done channel first to handle clean shutdown - select { - case <-done: - return nil - default: - } - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - return fmt.Errorf("Jetstream closed the subscription before the event arrived: %w", err) - } - if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { - return fmt.Errorf("Jetstream hung up before the event arrived: %w", err) - } - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - // The deadline is the whole budget, so its expiry is the answer: - // no matching event arrived. Reading on would be reading a - // connection gorilla has already marked failed. - return fmt.Errorf("no matching event within %s", jetstreamReadBudget) - } - // Still nil: this one only happens when the socket was closed - // underneath us during shutdown, which the done check above - // has already established is not a missing event. - if strings.Contains(err.Error(), "use of closed network connection") { - return nil - } - return fmt.Errorf("failed to read Jetstream message: %w", err) - } - - // Check if this is the event we're looking for - if event.Did == targetDID && event.Kind == "commit" && - event.Commit != nil && event.Commit.Collection == "social.coves.community.profile" && - event.Commit.Operation == operation { - - // Process through consumer to index in AppView - if err := consumer.HandleEvent(ctx, &event); err != nil { - return fmt.Errorf("failed to process event: %w", err) - } - - select { - case eventChan <- &event: - return nil - case <-time.After(1 * time.Second): - return fmt.Errorf("timeout sending event to channel") - } - } - } - } -} -- 2.51.2