fluid-telegram/internal/compose/compose_test.go

592 lines
16 KiB
Go
Raw Normal View History

package compose
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"unicode/utf8"
)
const fixtureEntry = `---
id: hall-worker-ada-lens
type: worker-entry
worker_kind: human
display_name: "Ada Lovelace"
created_at: "2026-10-05T00:00:00Z"
recorded_at: "2026-10-05"
status: complete
---
# The lens stayed honest
## Who I was
A paragraph that must not be quoted.
## What I would want remembered
Count the clock before comparing the numbers.
Don't trim the clause & don't paraphrase.
A later duplicate can cross a boundary.
## Durable legacy
- some path that must not be quoted
`
func TestComposeQuotesRememberedSection(t *testing.T) {
d, err := Compose(fixtureEntry, Source{Base: "https://example.test"})
if err != nil {
t.Fatal(err)
}
if d.Variant != Variant {
t.Fatalf("variant = %q", d.Variant)
}
if d.SourceEntryID != "hall-worker-ada-lens" {
t.Fatalf("source_entry_id = %q", d.SourceEntryID)
}
if d.SubjectDisplayName != "Ada Lovelace" {
t.Fatalf("display name = %q", d.SubjectDisplayName)
}
wantURL := "https://example.test/hall-of-helix/hall-worker-ada-lens"
if d.SourceEntryURL != wantURL {
t.Fatalf("source url = %q", d.SourceEntryURL)
}
if d.ReviewedBy != "" || d.SubjectConsent != "" || d.Approved || d.Publication != nil {
t.Fatalf("fresh draft carries review state: %+v", d)
}
if !strings.Contains(d.PostID, "hall-worker-ada-lens") || !strings.Contains(d.PostID, Variant) {
t.Fatalf("post_id = %q, want entry id and variant", d.PostID)
}
if len(d.PostID) > 128 {
t.Fatalf("post_id length %d", len(d.PostID))
}
_, body, err := splitFrontmatter(fixtureEntry)
if err != nil {
t.Fatal(err)
}
remembered, ok := section(body, rememberedHeading)
if !ok {
t.Fatal("fixture section missing")
}
if got := unquote(d.Body); got != remembered {
t.Fatalf("quotation is not verbatim:\n got: %q\nwant: %q", got, remembered)
}
lines := strings.Split(d.Body, "\n")
if lines[0] != "Ada Lovelace" || lines[1] != "The lens stayed honest" {
t.Fatalf("body opening = %q", lines[:2])
}
if lines[len(lines)-1] != wantURL {
t.Fatalf("body does not end with the source link: %q", lines[len(lines)-1])
}
if strings.Contains(d.Body, "must not be quoted") {
t.Fatalf("body includes text outside the remembered section:\n%s", d.Body)
}
again, err := Compose(fixtureEntry, Source{Base: "https://example.test/"})
if err != nil {
t.Fatal(err)
}
if again.PostID != d.PostID || again.SourceEntryURL != d.SourceEntryURL {
t.Fatalf("trailing slash on the base changed the draft: %+v vs %+v", again.SourceEntryURL, d.SourceEntryURL)
}
}
func TestComposePostIDFollowsTheText(t *testing.T) {
src := Source{URL: "https://h.test/entries/ada"}
first, err := Compose(fixtureEntry, src)
if err != nil {
t.Fatal(err)
}
second, err := Compose(fixtureEntry, src)
if err != nil {
t.Fatal(err)
}
if first.PostID != second.PostID {
t.Fatalf("same text produced %q and %q", first.PostID, second.PostID)
}
if first.SourceEntryURL != "https://h.test/entries/ada" {
t.Fatalf("explicit source URL was rewritten: %s", first.SourceEntryURL)
}
changed := strings.Replace(fixtureEntry, "Count the clock", "Count the clocks", 1)
third, err := Compose(changed, src)
if err != nil {
t.Fatal(err)
}
if third.PostID == first.PostID {
t.Fatal("recomposition kept the same post_id")
}
if !strings.Contains(unquote(third.Body), "Count the clocks") {
t.Fatalf("changed words were not quoted: %s", third.Body)
}
}
func TestComposeRefusesWithoutRememberedSection(t *testing.T) {
raw := `---
id: hall-worker-ada-lens
type: worker-entry
display_name: Ada Lovelace
status: complete
---
# The lens stayed honest
## Who I was
A paragraph that must not be quoted.
`
d, err := Compose(raw, Source{URL: "https://h.test/e"})
if !errors.Is(err, ErrRemembered) {
t.Fatalf("err = %v", err)
}
if d != (Draft{}) {
t.Fatalf("refused compose returned a draft: %+v", d)
}
}
func TestComposeRefusesEmptyRememberedSection(t *testing.T) {
for _, remembered := range []string{"", " ", "\n\n"} {
d, err := Compose(entryWith(remembered), Source{URL: "https://h.test/e"})
if !errors.Is(err, ErrRemembered) {
t.Fatalf("remembered %q: err = %v", remembered, err)
}
if d.Body != "" {
t.Fatal("empty section still produced a body")
}
}
}
func TestComposeRefusesIncompleteEntry(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{name: "no frontmatter", raw: "# Title\n\n## What I would want remembered\n\nx\n", want: "frontmatter"},
{name: "wrong type", raw: mustReplace(t, "type: worker-entry", "type: note"), want: "worker-entry"},
{name: "no title", raw: mustReplace(t, "# The lens stayed honest\n", ""), want: "title"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Compose(tc.raw, Source{URL: "https://h.test/e"})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v", err)
}
})
}
}
func TestComposeRequiresExplicitSource(t *testing.T) {
_, err := Compose(fixtureEntry, Source{})
if !errors.Is(err, ErrSource) {
t.Fatalf("missing source: %v", err)
}
_, err = Compose(fixtureEntry, Source{URL: "https://a.test/e", Base: "https://b.test"})
if err == nil {
t.Fatal("both source URL and base URL were accepted")
}
_, err = Compose(fixtureEntry, Source{Base: "/hall-of-helix"})
if !errors.Is(err, ErrSource) {
t.Fatalf("relative base: %v", err)
}
d, err := Compose(fixtureEntry, Source{Base: "https://example.test/docs/"})
if err != nil {
t.Fatal(err)
}
if d.SourceEntryURL != "https://example.test/docs/hall-of-helix/hall-worker-ada-lens" {
t.Fatalf("url = %s", d.SourceEntryURL)
}
}
func TestComposeRefusesBodyOver4096(t *testing.T) {
src := Source{URL: "https://h.test/e"}
l1 := composedLen(t, src, "a")
l2 := composedLen(t, src, "aa")
if l2-l1 != 1 {
t.Fatalf("body length is not linear in the quotation: %d then %d", l1, l2)
}
n := 1 + (4096 - l1)
if n < 1 {
t.Fatal("fixture overhead already exceeds 4096")
}
if got := composedLen(t, src, strings.Repeat("a", n)); got != 4096 {
t.Fatalf("length at the limit = %d", got)
}
d, err := Compose(entryWith(strings.Repeat("a", n+1)), src)
if !errors.Is(err, ErrTooLong) {
t.Fatalf("err = %v", err)
}
if d != (Draft{}) {
t.Fatalf("over-long compose returned a draft (%d characters)", utf8.RuneCountInString(d.Body))
}
}
func TestPutDoesNotApprove(t *testing.T) {
q := openQueue(t)
d, err := Compose(fixtureEntry, Source{Base: "https://example.test"})
if err != nil {
t.Fatal(err)
}
d.ReviewedBy = "sneaky"
d.SubjectConsent = "not-required"
d.Approved = true
if err := q.Put(d); err != nil {
t.Fatal(err)
}
got, err := q.Get(d.PostID)
if err != nil {
t.Fatal(err)
}
if got.Approved || got.ReviewedBy != "" || got.SubjectConsent != "" {
t.Fatalf("put stored review state: %+v", got)
}
raw, err := os.ReadFile(filepath.Join(q.dir, d.PostID+".json"))
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if _, ok := m["reviewed_by"]; ok {
t.Fatal("draft file has reviewed_by")
}
if _, ok := m["subject_consent"]; ok {
t.Fatal("draft file has subject_consent")
}
}
func TestApproveRequiresCallerConsent(t *testing.T) {
q := openQueue(t)
d := mustPut(t, q)
for _, consent := range []string{"", "implicit", "not required", "NOT-REQUIRED", "absence-of-objection"} {
_, err := q.Approve(d.PostID, "Ada", consent)
if !errors.Is(err, ErrConsent) && !errors.Is(err, ErrReviewer) {
t.Fatalf("consent %q: err = %v", consent, err)
}
got, err := q.Get(d.PostID)
if err != nil {
t.Fatal(err)
}
if got.Approved || got.SubjectConsent != "" || got.ReviewedBy != "" {
t.Fatalf("consent %q changed the draft: %+v", consent, got)
}
}
if _, err := q.Approve(d.PostID, " ", "explicit"); !errors.Is(err, ErrReviewer) {
t.Fatalf("blank reviewer: %v", err)
}
for _, consent := range []string{"explicit", "standing", "not-required"} {
got, err := q.Approve(d.PostID, "Ada", consent)
if err != nil {
t.Fatalf("consent %q: %v", consent, err)
}
if !got.Approved || got.ReviewedBy != "Ada" || got.SubjectConsent != consent {
t.Fatalf("approved draft = %+v", got)
}
}
}
func TestPublishRefusedBeforeApproval(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
q := openQueue(t)
d := mustPut(t, q)
_, err := q.Publish(context.Background(), srv.URL, d.PostID)
if !errors.Is(err, ErrNotApproved) {
t.Fatalf("err = %v", err)
}
if hits != 0 {
t.Fatalf("server was hit %d times before approval", hits)
}
}
func TestApproveThenPublishPostsContractFields(t *testing.T) {
var got map[string]any
var rawBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/channel-posts" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
var err error
rawBody, err = io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if err := json.Unmarshal(rawBody, &got); err != nil {
t.Errorf("json: %v", err)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"state":"published"}`))
}))
defer srv.Close()
q := openQueue(t)
d := mustPut(t, q)
if _, err := q.Approve(d.PostID, "Bernd", "explicit"); err != nil {
t.Fatal(err)
}
published, err := q.Publish(context.Background(), srv.URL, d.PostID)
if err != nil {
t.Fatal(err)
}
if published.Publication == nil || published.Publication.StatusCode != http.StatusCreated {
t.Fatalf("publication = %+v", published.Publication)
}
stored, err := q.Get(d.PostID)
if err != nil {
t.Fatal(err)
}
if stored.Publication == nil || stored.Publication.Body != `{"state":"published"}` {
t.Fatalf("queue publication = %+v", stored.Publication)
}
wantKeys := []string{
"post_id", "body", "source_entry_id", "source_entry_url",
"reviewed_by", "subject_consent", "variant", "subject_display_name",
}
if len(got) != len(wantKeys) {
t.Fatalf("POST keys = %v", keys(got))
}
for _, k := range wantKeys {
if _, ok := got[k]; !ok {
t.Errorf("POST body missing %s", k)
}
}
if got["post_id"] != d.PostID ||
got["body"] != d.Body ||
got["source_entry_id"] != d.SourceEntryID ||
got["source_entry_url"] != d.SourceEntryURL ||
got["reviewed_by"] != "Bernd" ||
got["subject_consent"] != "explicit" ||
got["variant"] != Variant ||
got["subject_display_name"] != d.SubjectDisplayName {
t.Fatalf("POST body = %#v", got)
}
if bytesContainsField(rawBody, "approved") || bytesContainsField(rawBody, "publication") {
t.Fatalf("POST body has queue fields: %s", rawBody)
}
}
func TestPublishRecords200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"state":"published"}`))
}))
defer srv.Close()
q := openQueue(t)
d := mustPut(t, q)
if _, err := q.Approve(d.PostID, "Bernd", "standing"); err != nil {
t.Fatal(err)
}
got, err := q.Publish(context.Background(), srv.URL, d.PostID)
if err != nil {
t.Fatal(err)
}
if got.Publication == nil || got.Publication.StatusCode != http.StatusOK {
t.Fatalf("publication = %+v", got.Publication)
}
}
func TestPublishRefusesTamperedQueue(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
q := openQueue(t)
d := mustPut(t, q)
d.Approved = true
d.ReviewedBy = "Ada"
d.SubjectConsent = "assumed"
if err := q.save(d); err != nil {
t.Fatal(err)
}
_, err := q.Publish(context.Background(), srv.URL, d.PostID)
if !errors.Is(err, ErrConsent) {
t.Fatalf("bad consent: %v", err)
}
d.SubjectConsent = "explicit"
d.ReviewedBy = ""
if err := q.save(d); err != nil {
t.Fatal(err)
}
_, err = q.Publish(context.Background(), srv.URL, d.PostID)
if !errors.Is(err, ErrReviewer) {
t.Fatalf("missing reviewer: %v", err)
}
if hits != 0 {
t.Fatalf("tampered drafts were posted %d times", hits)
}
}
func TestPublishRefusesCrossHostRedirect(t *testing.T) {
var otherHits int
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
otherHits++
w.WriteHeader(http.StatusCreated)
}))
defer other.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, other.URL+"/v1/channel-posts", http.StatusTemporaryRedirect)
}))
defer srv.Close()
q := openQueue(t)
d := mustPut(t, q)
if _, err := q.Approve(d.PostID, "Bernd", "explicit"); err != nil {
t.Fatal(err)
}
_, err := q.Publish(context.Background(), srv.URL, d.PostID)
if !errors.Is(err, ErrCrossHost) {
t.Fatalf("err = %v", err)
}
if otherHits != 0 {
t.Fatalf("followed redirect to another host (%d hits)", otherHits)
}
stored, err := q.Get(d.PostID)
if err != nil {
t.Fatal(err)
}
if stored.Publication != nil {
t.Fatal("cross-host redirect was recorded as publication")
}
}
func TestImportGraphExcludesAdapter(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
root := filepath.Join(filepath.Dir(file), "..", "..")
forbidden := []string{
"github.com/tegwick/fluid-telegram/internal/adapter",
"github.com/tegwick/fluid-telegram/internal/tg",
"github.com/tegwick/fluid-telegram/internal/apply",
"github.com/gotd/td",
}
for _, pkg := range []string{
"github.com/tegwick/fluid-telegram/internal/compose",
"github.com/tegwick/fluid-telegram/cmd/compose",
} {
cmd := exec.Command("go", "list", "-mod=readonly", "-deps", "-f", "{{.ImportPath}}", pkg)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go list %s: %v\n%s", pkg, err, out)
}
for _, line := range strings.Split(string(out), "\n") {
for _, bad := range forbidden {
if line == bad || strings.HasPrefix(line, bad+"/") {
t.Errorf("%s imports %s", pkg, line)
}
}
}
}
}
func entryWith(remembered string) string {
return `---
id: hall-worker-ada-lens
type: worker-entry
display_name: "Ada Lovelace"
status: complete
---
# The lens stayed honest
## Who I was
A paragraph that must not be quoted.
## What I would want remembered
` + remembered + `
## Durable legacy
- some path that must not be quoted
`
}
func mustReplace(t *testing.T, old, new string) string {
t.Helper()
if !strings.Contains(fixtureEntry, old) {
t.Fatalf("fixture lacks %q", old)
}
return strings.Replace(fixtureEntry, old, new, 1)
}
func composedLen(t *testing.T, src Source, remembered string) int {
t.Helper()
d, err := Compose(entryWith(remembered), src)
if err != nil {
t.Fatal(err)
}
return utf8.RuneCountInString(d.Body)
}
func openQueue(t *testing.T) *Queue {
t.Helper()
q, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
return q
}
func mustPut(t *testing.T, q *Queue) Draft {
t.Helper()
d, err := Compose(fixtureEntry, Source{Base: "https://example.test"})
if err != nil {
t.Fatal(err)
}
if err := q.Put(d); err != nil {
t.Fatal(err)
}
return d
}
func unquote(body string) string {
var lines []string
for _, line := range strings.Split(body, "\n") {
switch {
case line == ">":
lines = append(lines, "")
case strings.HasPrefix(line, "> "):
lines = append(lines, strings.TrimPrefix(line, "> "))
}
}
return strings.Join(lines, "\n")
}
func keys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func bytesContainsField(raw []byte, field string) bool {
return strings.Contains(string(raw), `"`+field+`"`)
}