fluid-telegram/internal/adapter/adapter_test.go
tegwick 86cbb43965 FT-WP-0001-T14: upload post images instead of linking them
The adapter fetches visual_url from an allowlisted host (https, image types,
10 MB cap, optional read token) and uploads it as multipart sendPhoto, so the
forge can close without breaking post images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
2026-10-08 20:55:32 +02:00

266 lines
7.1 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
uploads []Photo
failNext error
}
func (f *fakeSender) UploadPhoto(_ context.Context, _ int64, photo Photo, captionHTML string) (int, error) {
if f.failNext != nil {
err := f.failNext
f.failNext = nil
return 0, err
}
f.nextID++
f.uploads = append(f.uploads, photo)
f.captions = append(f.captions, captionHTML)
return f.nextID, nil
}
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))
}
}
func TestPublishReturnsTelegramMessageID(t *testing.T) {
snd := &fakeSender{nextID: 6}
a := New(Config{ChatID: -1004427430639}, 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())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["telegram_message_id"] != float64(7) {
t.Fatalf("telegram_message_id = %v", got["telegram_message_id"])
}
if got["telegram_message_link"] != "https://t.me/c/4427430639/7" {
t.Fatalf("telegram_message_link = %v", got["telegram_message_link"])
}
}
func TestMessageLinkOnlyForChannels(t *testing.T) {
if l := messageLink(-1001, 3); l != "" {
t.Fatalf("non-channel chat got a link: %s", l)
}
}