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
228 lines
6 KiB
Go
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))
|
|
}
|
|
}
|