package adapter import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" ) type fakeSender struct { nextID int sent []string captions []string edits []string failNext error } func (f *fakeSender) SendMessage(_ context.Context, _ int64, html string) (int, error) { if f.failNext != nil { err := f.failNext f.failNext = nil return 0, err } f.nextID++ f.sent = append(f.sent, html) return f.nextID, nil } func (f *fakeSender) SendPhoto(_ context.Context, _ int64, _, captionHTML string) (int, error) { if f.failNext != nil { err := f.failNext f.failNext = nil return 0, err } f.nextID++ f.captions = append(f.captions, captionHTML) return f.nextID, nil } func (f *fakeSender) EditMessageText(_ context.Context, _ int64, _ int, html string) error { f.edits = append(f.edits, html) return nil } func (f *fakeSender) EditMessageCaption(_ context.Context, _ int64, _ int, captionHTML string) error { f.edits = append(f.edits, captionHTML) return nil } func validReq(postID string) postRequest { return postRequest{ PostID: postID, Body: "a short post about the work", SourceEntryID: "entry-1", SourceEntryURL: "https://hall.example/entry-1", ReviewedBy: "alice", SubjectConsent: "standing", Variant: "v1", } } func post(t *testing.T, h http.Handler, req postRequest) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(req) if err != nil { t.Fatal(err) } r := httptest.NewRequest(http.MethodPost, "/v1/channel-posts", bytes.NewReader(body)) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func TestPublishSucceeds(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) w := post(t, a.Handler(), validReq("p1")) if w.Code != http.StatusCreated { t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) } if len(snd.sent) != 1 { t.Fatalf("expected one message sent, got %d", len(snd.sent)) } } func TestPublishRefusesWithoutReview(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) req := validReq("p1") req.ReviewedBy = "" w := post(t, a.Handler(), req) if w.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", w.Code) } if len(snd.sent) != 0 { t.Fatal("expected no message sent") } } func TestPublishRefusesWithoutConsentBasis(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) req := validReq("p1") req.SubjectConsent = "" w := post(t, a.Handler(), req) if w.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", w.Code) } } func TestPublishRefusesUnrecognisedConsentBasis(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) req := validReq("p1") req.SubjectConsent = "vibes" w := post(t, a.Handler(), req) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Code) } } func TestPublishRefusesOverLimitForTextOnly(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1, AttachVisual: false}, snd, NewMemStore(), nil) req := validReq("p1") req.Body = fmt.Sprintf("%4097s", "x") w := post(t, a.Handler(), req) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Code) } } func TestPublishRefusesOverCaptionLimitWhenVisualAttached(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1, AttachVisual: true}, snd, NewMemStore(), nil) req := validReq("p1") req.Body = fmt.Sprintf("%1025s", "x") req.VisualURL = "https://example.com/img.png" w := post(t, a.Handler(), req) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Code) } } func TestPublishIsIdempotentOnPostID(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) req := validReq("p1") w1 := post(t, a.Handler(), req) if w1.Code != http.StatusCreated { t.Fatalf("first publish: status = %d", w1.Code) } w2 := post(t, a.Handler(), req) if w2.Code != http.StatusOK { t.Fatalf("repeat publish: status = %d, want 200", w2.Code) } if len(snd.sent) != 1 { t.Fatalf("expected exactly one Telegram send, got %d", len(snd.sent)) } } func TestPublishEditsWhenSamePostIDHasNewBody(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) req := validReq("p1") post(t, a.Handler(), req) req.Body = "a revised short post about the work" w := post(t, a.Handler(), req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if len(snd.edits) != 1 { t.Fatalf("expected one edit, got %d", len(snd.edits)) } if len(snd.sent) != 1 { t.Fatalf("expected no additional send, got %d total sends", len(snd.sent)) } } func TestPublishMapsRateLimitTo502(t *testing.T) { snd := &fakeSender{failNext: &RateLimitError{RetryAfterSeconds: 30}} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) w := post(t, a.Handler(), validReq("p1")) if w.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502", w.Code) } if w.Header().Get("Retry-After") != "30" { t.Fatalf("Retry-After = %q, want 30", w.Header().Get("Retry-After")) } } func TestGetPostNotFound(t *testing.T) { a := New(Config{ChatID: 1}, &fakeSender{}, NewMemStore(), nil) r := httptest.NewRequest(http.MethodGet, "/v1/channel-posts/nope", nil) w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } } func TestListPosts(t *testing.T) { snd := &fakeSender{} a := New(Config{ChatID: 1}, snd, NewMemStore(), nil) post(t, a.Handler(), validReq("p1")) post(t, a.Handler(), validReq("p2")) r := httptest.NewRequest(http.MethodGet, "/v1/channel-posts", nil) w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var resp struct { Posts []map[string]any `json:"posts"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp.Posts) != 2 { t.Fatalf("expected 2 posts, got %d", len(resp.Posts)) } }