fluid-telegram/internal/adapter/adapter_test.go
tegwick bfebca3aee Implement the R-1 adapter (FT-WP-0001 T05/T09); mark workplans blocked
Builds internal/adapter and cmd/adapter: the three contracts/r1.openapi.yaml
operations, idempotency on post_id, the reviewed_by/consent-basis refusals,
per-form (R-2/R-3) length limits, and Markdown-to-Telegram rendering, all
against a Sender interface so it's testable without a live bot token.

Every other open task in FT-WP-0001 and FT-WP-0002 needs something this repo
can't supply on its own: the human session bootstrap in FT-WP-0002 T01, the
fluid CLI from fluid-core, or a person's judgment (consent basis, editorial
review, publishing pace). Set both workplans to status: blocked and flagged
FT-WP-0002-T01 needs_human on the hub, since it's the single blocker gating
everything else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 278411@bnt-lap001
Assistant-Session: 19568045-15bc-4b88-ba3d-8e95b4f9c6f1
2026-09-27 23:15:37 +02:00

228 lines
6 KiB
Go

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))
}
}