···2323// Record represents a single record from a PDS.
2424type Record = atp.Record
25252626+// ClientProvider returns an authenticated atp.Client for the given DID and session.
2727+type ClientProvider func(ctx context.Context, did syntax.DID, sessionID string) (*atp.Client, error)
2828+2629// Client wraps the atproto API client for making authenticated requests to a PDS.
2730type Client struct {
2828- oauth *OAuthManager
3131+ getClient ClientProvider
2932}
30333131-// NewClient creates a new atproto client.
3434+// NewClient creates a new atproto client that authenticates via OAuth.
3235func NewClient(oauth *OAuthManager) *Client {
3333- return &Client{oauth: oauth}
3636+ return &Client{getClient: oauthProvider(oauth)}
3437}
35383636-// getAtpClient resumes an OAuth session and returns an atp.Client with OTel-instrumented transport.
3737-func (c *Client) getAtpClient(ctx context.Context, did syntax.DID, sessionID string) (*atp.Client, error) {
3838- session, err := c.oauth.app.ResumeSession(ctx, did, sessionID)
3939- if err != nil {
4040- return nil, fmt.Errorf("%w: %w", ErrSessionExpired, err)
4141- }
3939+// NewClientWithProvider creates a client with a custom authentication provider.
4040+// This is useful for testing with password-auth or pre-authenticated clients.
4141+func NewClientWithProvider(provider ClientProvider) *Client {
4242+ return &Client{getClient: provider}
4343+}
42444343- apiClient := session.APIClient()
4545+// oauthProvider returns a ClientProvider that resumes OAuth sessions with OTel-instrumented transport.
4646+func oauthProvider(oauth *OAuthManager) ClientProvider {
4747+ return func(ctx context.Context, did syntax.DID, sessionID string) (*atp.Client, error) {
4848+ session, err := oauth.app.ResumeSession(ctx, did, sessionID)
4949+ if err != nil {
5050+ return nil, fmt.Errorf("%w: %w", ErrSessionExpired, err)
5151+ }
44524545- // Wrap transport with OTel instrumentation.
4646- baseTransport := apiClient.Client.Transport
4747- if baseTransport == nil {
4848- baseTransport = http.DefaultTransport
5353+ apiClient := session.APIClient()
5454+5555+ // Wrap transport with OTel instrumentation.
5656+ baseTransport := apiClient.Client.Transport
5757+ if baseTransport == nil {
5858+ baseTransport = http.DefaultTransport
5959+ }
6060+ apiClient.Client = &http.Client{
6161+ Transport: otelhttp.NewTransport(baseTransport),
6262+ Timeout: apiClient.Client.Timeout,
6363+ CheckRedirect: apiClient.Client.CheckRedirect,
6464+ Jar: apiClient.Client.Jar,
6565+ }
6666+6767+ return atp.NewClient(apiClient, did), nil
4968 }
5050- apiClient.Client = &http.Client{
5151- Transport: otelhttp.NewTransport(baseTransport),
5252- Timeout: apiClient.Client.Timeout,
5353- CheckRedirect: apiClient.Client.CheckRedirect,
5454- Jar: apiClient.Client.Jar,
5555- }
6969+}
56705757- return atp.NewClient(apiClient, did), nil
7171+// getAtpClient returns an authenticated atp.Client using the configured provider.
7272+func (c *Client) getAtpClient(ctx context.Context, did syntax.DID, sessionID string) (*atp.Client, error) {
7373+ return c.getClient(ctx, did, sessionID)
5874}
59756076// --- Input/Output types (kept for caller compatibility) ---
+9
internal/atproto/oauth.go
···196196 return context.WithValue(ctx, contextKeyUserDID, did)
197197}
198198199199+// ContextWithAuth returns a context populated with both the authenticated DID
200200+// and session ID. This is the test-friendly equivalent of what AuthMiddleware
201201+// installs after validating real cookies.
202202+func ContextWithAuth(ctx context.Context, did, sessionID string) context.Context {
203203+ ctx = context.WithValue(ctx, contextKeyUserDID, did)
204204+ ctx = context.WithValue(ctx, contextKeySessionID, sessionID)
205205+ return ctx
206206+}
207207+199208// GetAuthenticatedDID retrieves the authenticated user's DID from the request context
200209func GetAuthenticatedDID(ctx context.Context) (string, error) {
201210 did, ok := ctx.Value(contextKeyUserDID).(string)
+3
justfile
···1414 @templ generate
1515 @go test ./... -cover -coverprofile=cover.out
16161717+test-integration:
1818+ @cd tests/integration && go test -v ./... -count=1
1919+1720style:
1821 @nix develop --command tailwindcss -i static/css/app.css -o static/css/output.css --minify
···11+package integration
22+33+import (
44+ "encoding/json"
55+ "net/http"
66+ "net/url"
77+ "testing"
88+99+ "arabica/internal/models"
1010+1111+ "github.com/stretchr/testify/assert"
1212+ "github.com/stretchr/testify/require"
1313+)
1414+1515+// TestHTTP_RoasterCreateFlow exercises the full POST /api/roasters flow:
1616+// authenticated request → handler → AtprotoStore → real PDS, then verifies
1717+// the record exists by listing it through a separate GET.
1818+func TestHTTP_RoasterCreateFlow(t *testing.T) {
1919+ h := StartHarness(t, nil)
2020+2121+ form := url.Values{}
2222+ form.Set("name", "Counter Culture")
2323+ form.Set("location", "Durham, NC")
2424+ form.Set("website", "https://counterculturecoffee.com")
2525+2626+ resp := h.PostForm("/api/roasters", form)
2727+ body := ReadBody(t, resp)
2828+ require.Equal(t, 200, resp.StatusCode, statusErr(resp, body))
2929+3030+ var created models.Roaster
3131+ require.NoError(t, json.Unmarshal([]byte(body), &created))
3232+ assert.Equal(t, "Counter Culture", created.Name)
3333+ assert.Equal(t, "Durham, NC", created.Location)
3434+ assert.NotEmpty(t, created.RKey)
3535+}
3636+3737+// TestHTTP_UnauthenticatedPostRejected ensures POST endpoints reject requests
3838+// without test auth headers (simulating an unauthenticated client).
3939+func TestHTTP_UnauthenticatedPostRejected(t *testing.T) {
4040+ h := StartHarness(t, nil)
4141+4242+ // Use a bare http.Client with no auth headers (and no cookies).
4343+ req, err := http.NewRequest("POST", h.URL("/api/roasters"), nil)
4444+ require.NoError(t, err)
4545+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
4646+ req.Header.Set("Origin", h.Server.URL)
4747+4848+ resp, err := http.DefaultClient.Do(req)
4949+ require.NoError(t, err)
5050+ defer resp.Body.Close()
5151+5252+ assert.Equal(t, 401, resp.StatusCode)
5353+}
5454+5555+// TestHTTP_RoasterCreateValidationError ensures the handler returns a 400
5656+// when the request body is invalid (empty name).
5757+func TestHTTP_RoasterCreateValidationError(t *testing.T) {
5858+ h := StartHarness(t, nil)
5959+6060+ form := url.Values{}
6161+ form.Set("name", "") // empty: should fail validation
6262+6363+ resp := h.PostForm("/api/roasters", form)
6464+ body := ReadBody(t, resp)
6565+6666+ assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body))
6767+}
6868+6969+// TestHTTP_BeanCreateLinksToRoaster exercises a multi-step flow: create a
7070+// roaster, then create a bean referencing it. Verifies the cross-entity
7171+// reference round-trips through the handler layer.
7272+func TestHTTP_BeanCreateLinksToRoaster(t *testing.T) {
7373+ h := StartHarness(t, nil)
7474+7575+ // Step 1: create roaster
7676+ roasterForm := url.Values{}
7777+ roasterForm.Set("name", "Onyx Coffee Lab")
7878+ roasterResp := h.PostForm("/api/roasters", roasterForm)
7979+ roasterBody := ReadBody(t, roasterResp)
8080+ require.Equal(t, 200, roasterResp.StatusCode, statusErr(roasterResp, roasterBody))
8181+8282+ var roaster models.Roaster
8383+ require.NoError(t, json.Unmarshal([]byte(roasterBody), &roaster))
8484+ require.NotEmpty(t, roaster.RKey)
8585+8686+ // Step 2: create bean referencing roaster
8787+ beanForm := url.Values{}
8888+ beanForm.Set("name", "Geometry")
8989+ beanForm.Set("roaster_rkey", roaster.RKey)
9090+ beanForm.Set("roast_level", "Medium")
9191+9292+ beanResp := h.PostForm("/api/beans", beanForm)
9393+ beanBody := ReadBody(t, beanResp)
9494+ require.Equal(t, 200, beanResp.StatusCode, statusErr(beanResp, beanBody))
9595+9696+ var bean models.Bean
9797+ require.NoError(t, json.Unmarshal([]byte(beanBody), &bean))
9898+ assert.Equal(t, "Geometry", bean.Name)
9999+ assert.Equal(t, roaster.RKey, bean.RoasterRKey)
100100+}
+376
tests/integration/harness.go
···11+package integration
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "fmt"
88+ "io"
99+ stdlog "log"
1010+ "log/slog"
1111+ "net/http"
1212+ "net/http/httptest"
1313+ "net/url"
1414+ "os"
1515+ "strings"
1616+ "sync"
1717+ "testing"
1818+ "time"
1919+2020+ "arabica/internal/atproto"
2121+ "arabica/internal/feed"
2222+ "arabica/internal/firehose"
2323+ "arabica/internal/handlers"
2424+ "arabica/internal/routing"
2525+2626+ "github.com/bluesky-social/indigo/atproto/atclient"
2727+ "github.com/bluesky-social/indigo/atproto/syntax"
2828+ "github.com/haileyok/cocoon/testpds"
2929+ "github.com/rs/zerolog"
3030+ zlog "github.com/rs/zerolog/log"
3131+ "github.com/stretchr/testify/require"
3232+ "tangled.org/pdewey.com/atp"
3333+)
3434+3535+// silenceLogs redirects all the noisy log outputs that show up during
3636+// integration tests (cocoon's stdlib log + slog + GORM, arabica's zerolog) to
3737+// io.Discard. This keeps `go test -v` output focused on actual test results.
3838+//
3939+// Without this, even passing runs scroll dozens of lines of GORM
4040+// "record not found" warnings, arabica request debug logs, and cocoon's
4141+// per-request access logs.
4242+//
4343+// Set INTEGRATION_VERBOSE=1 to keep the logs visible — useful when a test is
4444+// failing and you want to see what arabica or cocoon were doing at the time.
4545+//
4646+// Note: without -v, `go test` already captures per-test output and only prints
4747+// it on failure, so this silencing is mainly relevant for `go test -v`.
4848+func silenceLogs() {
4949+ if v := os.Getenv("INTEGRATION_VERBOSE"); v == "1" || v == "true" {
5050+ return
5151+ }
5252+ // stdlib log (some GORM logger configurations write here)
5353+ stdlog.SetOutput(io.Discard)
5454+ // log/slog (cocoon's slogecho middleware, server lifecycle logs)
5555+ slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
5656+ // zerolog (arabica's handler debug/info logs go through the global logger)
5757+ zlog.Logger = zerolog.New(io.Discard)
5858+}
5959+6060+// testAuthHeader is the request header used by the test harness to inject
6161+// authentication context bypassing real OAuth cookies. The middleware in
6262+// harnessAuthMiddleware reads this and populates the same context keys that
6363+// OAuthManager.AuthMiddleware would set.
6464+const (
6565+ testAuthDIDHeader = "X-Test-Auth-DID"
6666+ testAuthSessionHeader = "X-Test-Auth-Session"
6767+)
6868+6969+// Harness wires up a full arabica handler tree backed by an in-process test
7070+// PDS and exposes an httptest.Server. Auth is faked via custom headers so
7171+// tests can act as any DID without an OAuth dance.
7272+type Harness struct {
7373+ T *testing.T
7474+ PDS *testpds.TestPDS
7575+ Server *httptest.Server
7676+ Handler *handlers.Handler
7777+ FeedIndex *firehose.FeedIndex
7878+7979+ // PrimaryAccount is the default account created on harness setup.
8080+ PrimaryAccount TestAccount
8181+8282+ // Client is an http.Client preconfigured to authenticate as PrimaryAccount.
8383+ // Use Harness.NewClientForAccount for non-primary users.
8484+ Client *http.Client
8585+8686+ // accounts maps DID -> APIClient so the ClientProvider can route XRPC calls
8787+ // to the correct authenticated session for any registered account.
8888+ accountsMu sync.RWMutex
8989+ accounts map[string]*atclient.APIClient
9090+9191+ cleanup []func()
9292+}
9393+9494+// TestAccount holds credentials for a user on the test PDS.
9595+type TestAccount struct {
9696+ DID string
9797+ Handle string
9898+ Password string
9999+ AccessJwt string
100100+}
101101+102102+// HarnessOptions configures harness setup.
103103+type HarnessOptions struct {
104104+ // PrimaryHandle is the handle of the default account. Defaults to "alice.test".
105105+ PrimaryHandle string
106106+ // PrimaryEmail is the email of the default account. Defaults to "alice@test.com".
107107+ PrimaryEmail string
108108+ // PrimaryPassword is the password for the default account. Defaults to "hunter2".
109109+ PrimaryPassword string
110110+}
111111+112112+// StartHarness boots a test PDS, creates a primary account, builds the full
113113+// handler tree, and exposes everything as an httptest server.
114114+func StartHarness(t *testing.T, opts *HarnessOptions) *Harness {
115115+ t.Helper()
116116+117117+ silenceLogs()
118118+119119+ if opts == nil {
120120+ opts = &HarnessOptions{}
121121+ }
122122+ if opts.PrimaryHandle == "" {
123123+ opts.PrimaryHandle = "alice.test"
124124+ }
125125+ if opts.PrimaryEmail == "" {
126126+ opts.PrimaryEmail = "alice@test.com"
127127+ }
128128+ if opts.PrimaryPassword == "" {
129129+ opts.PrimaryPassword = "hunter2"
130130+ }
131131+132132+ pds := testpds.Start(t, nil)
133133+134134+ // Build an in-process FeedIndex (SQLite, temp dir) to back the witness
135135+ // cache and the suggestion endpoint. This is the same type production
136136+ // uses; the firehose consumer is not wired up, so the index is populated
137137+ // purely via write-through from store CRUD operations.
138138+ feedIndex, err := firehose.NewFeedIndex(t.TempDir()+"/feed-index.db", 1*time.Hour)
139139+ require.NoError(t, err)
140140+141141+ harness := &Harness{
142142+ T: t,
143143+ PDS: pds,
144144+ FeedIndex: feedIndex,
145145+ accounts: make(map[string]*atclient.APIClient),
146146+ }
147147+148148+ // Provider routes XRPC calls based on the DID in the request context. The
149149+ // harness pre-registers each account's APIClient so the right session is
150150+ // used per-DID for multi-user scenarios.
151151+ atprotoClient := atproto.NewClientWithProvider(func(ctx context.Context, d syntax.DID, _ string) (*atp.Client, error) {
152152+ harness.accountsMu.RLock()
153153+ api, ok := harness.accounts[d.String()]
154154+ harness.accountsMu.RUnlock()
155155+ if !ok {
156156+ return nil, fmt.Errorf("no APIClient registered for DID %s", d)
157157+ }
158158+ return atp.NewClient(api, d), nil
159159+ })
160160+161161+ // Construct dependencies the same way main.go does, minus the persistent
162162+ // stores. The OAuth manager is built but never exercised — its
163163+ // AuthMiddleware short-circuits when no cookies are present, leaving the
164164+ // context the harness middleware installed in place.
165165+ oauthMgr, err := atproto.NewOAuthManager("", "http://localhost/oauth/callback", nil)
166166+ require.NoError(t, err)
167167+168168+ sessionCache := atproto.NewSessionCache()
169169+ feedRegistry := feed.NewRegistry()
170170+ feedService := feed.NewService(feedRegistry)
171171+172172+ h := handlers.NewHandler(
173173+ oauthMgr,
174174+ atprotoClient,
175175+ sessionCache,
176176+ feedService,
177177+ feedRegistry,
178178+ handlers.Config{
179179+ SecureCookies: false,
180180+ PublicURL: "http://localhost",
181181+ },
182182+ )
183183+ h.SetFeedIndex(feedIndex)
184184+ h.SetWitnessCache(feedIndex)
185185+186186+ // Build the router with no moderation service (most tests don't need it).
187187+ logger := zerolog.Nop()
188188+ router := routing.SetupRouter(routing.Config{
189189+ Handlers: h,
190190+ OAuthManager: oauthMgr,
191191+ Logger: logger,
192192+ })
193193+194194+ // Wrap the router with the harness auth middleware so tests can pose as
195195+ // any DID via the X-Test-Auth-* headers.
196196+ authedRouter := harnessAuthMiddleware(router)
197197+198198+ server := httptest.NewServer(authedRouter)
199199+200200+ harness.Server = server
201201+ harness.Handler = h
202202+ harness.cleanup = append(harness.cleanup, server.Close, func() { _ = feedIndex.Close() })
203203+204204+ // Create the primary account and register it.
205205+ harness.PrimaryAccount = harness.CreateAccount(opts.PrimaryEmail, opts.PrimaryHandle, opts.PrimaryPassword)
206206+ harness.Client = harness.NewClientForAccount(harness.PrimaryAccount)
207207+208208+ t.Cleanup(func() {
209209+ for _, fn := range harness.cleanup {
210210+ fn()
211211+ }
212212+ })
213213+214214+ return harness
215215+}
216216+217217+// CreateAccount registers a new account on the test PDS, logs in via password
218218+// auth, and registers the resulting APIClient with the harness so the handler
219219+// tree can act as that user. Use this for multi-user test scenarios.
220220+func (h *Harness) CreateAccount(email, handle, password string) TestAccount {
221221+ h.T.Helper()
222222+223223+ acct := createAccountOnPDS(h.T, h.PDS.URL, email, handle, password)
224224+225225+ apiClient, err := atclient.LoginWithPasswordHost(
226226+ context.Background(), h.PDS.URL, acct.Handle, password, "", nil,
227227+ )
228228+ require.NoError(h.T, err)
229229+230230+ h.accountsMu.Lock()
231231+ h.accounts[acct.DID] = apiClient
232232+ h.accountsMu.Unlock()
233233+234234+ return acct
235235+}
236236+237237+// NewClientForAccount returns an http.Client that automatically attaches the
238238+// test auth headers for the given account. Use this when a test needs to act
239239+// as a non-primary user.
240240+func (h *Harness) NewClientForAccount(acct TestAccount) *http.Client {
241241+ return &http.Client{
242242+ Transport: &authInjectingTransport{
243243+ did: acct.DID,
244244+ sessionID: "test-session-" + acct.DID,
245245+ base: http.DefaultTransport,
246246+ },
247247+ }
248248+}
249249+250250+// URL returns the test server's base URL with the given path appended.
251251+func (h *Harness) URL(path string) string {
252252+ return h.Server.URL + path
253253+}
254254+255255+// PostForm posts a urlencoded form as the primary account and returns the response.
256256+func (h *Harness) PostForm(path string, form url.Values) *http.Response {
257257+ h.T.Helper()
258258+ req, err := http.NewRequest("POST", h.URL(path), strings.NewReader(form.Encode()))
259259+ require.NoError(h.T, err)
260260+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
261261+ resp, err := h.Client.Do(req)
262262+ require.NoError(h.T, err)
263263+ return resp
264264+}
265265+266266+// PostJSON posts a JSON body as the primary account and returns the response.
267267+func (h *Harness) PostJSON(path string, body any) *http.Response {
268268+ h.T.Helper()
269269+ buf, err := json.Marshal(body)
270270+ require.NoError(h.T, err)
271271+ req, err := http.NewRequest("POST", h.URL(path), bytes.NewReader(buf))
272272+ require.NoError(h.T, err)
273273+ req.Header.Set("Content-Type", "application/json")
274274+ resp, err := h.Client.Do(req)
275275+ require.NoError(h.T, err)
276276+ return resp
277277+}
278278+279279+// Get fetches a path as the primary account.
280280+func (h *Harness) Get(path string) *http.Response {
281281+ h.T.Helper()
282282+ req, err := http.NewRequest("GET", h.URL(path), nil)
283283+ require.NoError(h.T, err)
284284+ resp, err := h.Client.Do(req)
285285+ require.NoError(h.T, err)
286286+ return resp
287287+}
288288+289289+// ReadBody drains and returns the response body, closing it.
290290+func ReadBody(t *testing.T, resp *http.Response) string {
291291+ t.Helper()
292292+ defer resp.Body.Close()
293293+ body, err := io.ReadAll(resp.Body)
294294+ require.NoError(t, err)
295295+ return string(body)
296296+}
297297+298298+// --- internals ---
299299+300300+// harnessAuthMiddleware injects authentication context based on test headers.
301301+// Runs before the real OAuth middleware. When the OAuth middleware sees no
302302+// cookies, it passes the request through unchanged, leaving the harness
303303+// context intact.
304304+func harnessAuthMiddleware(next http.Handler) http.Handler {
305305+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
306306+ did := r.Header.Get(testAuthDIDHeader)
307307+ sessionID := r.Header.Get(testAuthSessionHeader)
308308+ if did != "" && sessionID != "" {
309309+ r = r.WithContext(atproto.ContextWithAuth(r.Context(), did, sessionID))
310310+ }
311311+ next.ServeHTTP(w, r)
312312+ })
313313+}
314314+315315+// authInjectingTransport adds the test auth headers to every request.
316316+type authInjectingTransport struct {
317317+ did string
318318+ sessionID string
319319+ base http.RoundTripper
320320+}
321321+322322+func (t *authInjectingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
323323+ clone := req.Clone(req.Context())
324324+ clone.Header.Set(testAuthDIDHeader, t.did)
325325+ clone.Header.Set(testAuthSessionHeader, t.sessionID)
326326+ // Set Origin to match the host so Go's CrossOriginProtection allows the request.
327327+ if clone.Header.Get("Origin") == "" {
328328+ clone.Header.Set("Origin", "http://"+req.Host)
329329+ }
330330+ return t.base.RoundTrip(clone)
331331+}
332332+333333+// createAccountOnPDS registers a new account on the test PDS and returns its credentials.
334334+func createAccountOnPDS(t *testing.T, pdsURL, email, handle, password string) TestAccount {
335335+ t.Helper()
336336+337337+ body, err := json.Marshal(map[string]string{
338338+ "email": email,
339339+ "handle": handle,
340340+ "password": password,
341341+ })
342342+ require.NoError(t, err)
343343+344344+ resp, err := http.Post(pdsURL+"/xrpc/com.atproto.server.createAccount", "application/json", bytes.NewReader(body))
345345+ require.NoError(t, err)
346346+ defer resp.Body.Close()
347347+348348+ respBody, err := io.ReadAll(resp.Body)
349349+ require.NoError(t, err)
350350+ require.Equal(t, 200, resp.StatusCode, "createAccount failed: %s", string(respBody))
351351+352352+ var result struct {
353353+ AccessJwt string `json:"accessJwt"`
354354+ Handle string `json:"handle"`
355355+ Did string `json:"did"`
356356+ }
357357+ require.NoError(t, json.Unmarshal(respBody, &result))
358358+359359+ return TestAccount{
360360+ DID: result.Did,
361361+ Handle: result.Handle,
362362+ Password: password,
363363+ AccessJwt: result.AccessJwt,
364364+ }
365365+}
366366+367367+// statusErr is a small helper for assertions that print useful diagnostics.
368368+// Bodies larger than 512 chars are truncated so a 404 HTML page doesn't drown
369369+// the test output.
370370+func statusErr(resp *http.Response, body string) string {
371371+ const maxBody = 512
372372+ if len(body) > maxBody {
373373+ body = body[:maxBody] + "... [truncated, " + fmt.Sprintf("%d", len(body)) + " bytes total]"
374374+ }
375375+ return fmt.Sprintf("status %d: %s", resp.StatusCode, body)
376376+}
···11+package integration
22+33+import (
44+ "encoding/json"
55+ "net/http"
66+ "net/url"
77+ "strings"
88+ "testing"
99+1010+ "arabica/internal/models"
1111+1212+ "github.com/stretchr/testify/assert"
1313+ "github.com/stretchr/testify/require"
1414+)
1515+1616+// suggestionResult mirrors suggestions.EntitySuggestion to keep this test
1717+// independent of the internal package import path.
1818+type suggestionResult struct {
1919+ Name string `json:"name"`
2020+ SourceURI string `json:"source_uri"`
2121+ Fields map[string]string `json:"fields"`
2222+ Count int `json:"count"`
2323+}
2424+2525+// postRoasterAs is a helper that creates a roaster on behalf of the given client
2626+// and returns the created entity. If sourceRef is non-empty, it sets the
2727+// source_ref field (used to track community adoption).
2828+func postRoasterAs(t *testing.T, h *Harness, client *http.Client, name, location, sourceRef string) models.Roaster {
2929+ t.Helper()
3030+ form := url.Values{}
3131+ form.Set("name", name)
3232+ if location != "" {
3333+ form.Set("location", location)
3434+ }
3535+ if sourceRef != "" {
3636+ form.Set("source_ref", sourceRef)
3737+ }
3838+ req, err := http.NewRequest("POST", h.URL("/api/roasters"), strings.NewReader(form.Encode()))
3939+ require.NoError(t, err)
4040+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
4141+ resp, err := client.Do(req)
4242+ require.NoError(t, err)
4343+ body := ReadBody(t, resp)
4444+ require.Equal(t, 200, resp.StatusCode, statusErr(resp, body))
4545+ var roaster models.Roaster
4646+ require.NoError(t, json.Unmarshal([]byte(body), &roaster))
4747+ return roaster
4848+}
4949+5050+// fetchSuggestions hits GET /api/suggestions/{entity}?q=... as the given client.
5151+func fetchSuggestions(t *testing.T, h *Harness, client *http.Client, entity, query string) []suggestionResult {
5252+ t.Helper()
5353+ req, err := http.NewRequest("GET", h.URL("/api/suggestions/"+entity+"?q="+url.QueryEscape(query)), nil)
5454+ require.NoError(t, err)
5555+ resp, err := client.Do(req)
5656+ require.NoError(t, err)
5757+ body := ReadBody(t, resp)
5858+ require.Equal(t, 200, resp.StatusCode, statusErr(resp, body))
5959+ var results []suggestionResult
6060+ require.NoError(t, json.Unmarshal([]byte(body), &results))
6161+ return results
6262+}
6363+6464+// TestHTTP_SuggestionDedupe verifies that when multiple users post a roaster
6565+// with the same fuzzy-name, the suggestion endpoint dedupes them into a
6666+// single result and counts all contributing DIDs.
6767+func TestHTTP_SuggestionDedupe(t *testing.T) {
6868+ h := StartHarness(t, nil)
6969+7070+ // Four users total: alice/bob/carol contribute roasters, dave queries.
7171+ // (The suggestion handler excludes the requester's own records.)
7272+ bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2")
7373+ carol := h.CreateAccount("carol@test.com", "carol.test", "hunter2")
7474+ dave := h.CreateAccount("dave@test.com", "dave.test", "hunter2")
7575+7676+ aliceClient := h.Client // primary == alice
7777+ bobClient := h.NewClientForAccount(bob)
7878+ carolClient := h.NewClientForAccount(carol)
7979+ daveClient := h.NewClientForAccount(dave)
8080+8181+ // All three create a roaster that should fuzzy-match into a single
8282+ // dedupe group ("Counter Culture" / "Counter Culture Coffee").
8383+ postRoasterAs(t, h, aliceClient, "Counter Culture", "Durham, NC", "")
8484+ postRoasterAs(t, h, bobClient, "Counter Culture Coffee", "Durham, NC", "")
8585+ postRoasterAs(t, h, carolClient, "Counter Culture", "Durham, NC", "")
8686+8787+ results := fetchSuggestions(t, h, daveClient, "roasters", "counter")
8888+ require.NotEmpty(t, results, "expected at least one suggestion")
8989+9090+ // Find the Counter Culture entry.
9191+ var cc *suggestionResult
9292+ for i := range results {
9393+ if strings.Contains(strings.ToLower(results[i].Name), "counter culture") {
9494+ cc = &results[i]
9595+ break
9696+ }
9797+ }
9898+ require.NotNil(t, cc, "expected a Counter Culture suggestion in results")
9999+100100+ // All three contributing users should be counted in a single dedupe group.
101101+ assert.Equal(t, 3, cc.Count, "all three contributing users should be counted")
102102+}
103103+104104+// TestHTTP_SuggestionScoring_ExcludesRequester verifies that the suggestion
105105+// handler hides the requesting user's own records (so users only see community
106106+// data, not their own data echoed back).
107107+func TestHTTP_SuggestionScoring_ExcludesRequester(t *testing.T) {
108108+ h := StartHarness(t, nil)
109109+110110+ bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2")
111111+ bobClient := h.NewClientForAccount(bob)
112112+113113+ // Alice creates a roaster.
114114+ postRoasterAs(t, h, h.Client, "Onyx Coffee Lab", "Rogers, AR", "")
115115+116116+ // Alice queries — should see nothing (her own roaster is excluded).
117117+ aliceResults := fetchSuggestions(t, h, h.Client, "roasters", "onyx")
118118+ assert.Empty(t, aliceResults, "querying user's own records should be excluded")
119119+120120+ // Bob queries — should see Alice's roaster.
121121+ bobResults := fetchSuggestions(t, h, bobClient, "roasters", "onyx")
122122+ require.Len(t, bobResults, 1)
123123+ assert.Equal(t, "Onyx Coffee Lab", bobResults[0].Name)
124124+}