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
This commit is contained in:
parent
f7f5302903
commit
bfebca3aee
10 changed files with 1052 additions and 6 deletions
228
internal/adapter/adapter_test.go
Normal file
228
internal/adapter/adapter_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue