diff --git a/docs/API.md b/docs/API.md index 5dfb1c4..0f6ea6e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -22,6 +22,10 @@ curl -X POST http://localhost:8080/api/pastes \ - `expires_in` is a Go duration string (`90m`, `6h`, `336h`). Omit for no expiry. - `visibility` is `public` or `unlisted`. +- Alternatively (or additionally), `public` may be sent as a boolean (#83): + `false` maps to `unlisted` and `true` maps to `public`. When both fields are + present, the boolean `public` takes precedence over the string `visibility`. + Omitting both defaults to `public`. - `burn_after_reads` sets how many reads the paste survives (default 1 when `burn_after_read` is true). A read is counted per unique viewer session; the same viewer returning within 15 minutes does not count again. diff --git a/internal/api/publicbool_test.go b/internal/api/publicbool_test.go new file mode 100644 index 0000000..b2320b6 --- /dev/null +++ b/internal/api/publicbool_test.go @@ -0,0 +1,97 @@ +package api + +// #83 regression tests: `public` boolean in the create payload must map to +// visibility (false -> unlisted, true -> public); string `visibility` still works. + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +func createPasteBody(t *testing.T, h *apiServer, body string) map[string]any { + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.routes().ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create: got %d: %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad json: %v", err) + } + return resp +} + +func getVis(t *testing.T, h *apiServer, id string) string { + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec := httptest.NewRecorder() + h.routes().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("get %s: got %d", id, rec.Code) + } + var resp struct { + Visibility string `json:"visibility"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad json: %v", err) + } + return resp.Visibility +} + +// TestPublicBooleanFalseMapsToUnlisted: {"public": false} must create an unlisted paste. +func TestPublicBooleanFalseMapsToUnlisted(t *testing.T) { + s := testServer(t) + resp := createPasteBody(t, s, `{"content":"x","public":false}`) + if v := getVis(t, s, resp["id"].(string)); v != "unlisted" { + t.Fatalf("public:false -> got visibility %q, want unlisted", v) + } +} + +// TestPublicBooleanTrueMapsToPublic: {"public": true} must create a public paste. +func TestPublicBooleanTrueMapsToPublic(t *testing.T) { + s := testServer(t) + resp := createPasteBody(t, s, `{"content":"x","public":true}`) + if v := getVis(t, s, resp["id"].(string)); v != "public" { + t.Fatalf("public:true -> got visibility %q, want public", v) + } +} + +// TestPublicBooleanOverridesString: boolean wins when both fields are sent. +func TestPublicBooleanOverridesString(t *testing.T) { + s := testServer(t) + resp := createPasteBody(t, s, `{"content":"x","visibility":"public","public":false}`) + if v := getVis(t, s, resp["id"].(string)); v != "unlisted" { + t.Fatalf("boolean override -> got %q, want unlisted", v) + } +} + +// TestVisibilityStringStillWorks: existing string contract unchanged. +func TestVisibilityStringStillWorks(t *testing.T) { + s := testServer(t) + resp := createPasteBody(t, s, `{"content":"x","visibility":"unlisted"}`) + if v := getVis(t, s, resp["id"].(string)); v != "unlisted" { + t.Fatalf("string field -> got %q, want unlisted", v) + } + resp = createPasteBody(t, s, `{"content":"y","visibility":"public"}`) + if v := getVis(t, s, resp["id"].(string)); v != "public" { + t.Fatalf("string field -> got %q, want public", v) + } +} + +// TestPublicListExcludesPublicFalse: {"public":false} pastes stay out of /api/public. +func TestPublicListExcludesPublicFalse(t *testing.T) { + s := testServer(t) + createPasteBody(t, s, `{"content":"hidden","public":false}`) + req := httptest.NewRequest("GET", "/api/public", nil) + rec := httptest.NewRecorder() + s.routes().ServeHTTP(rec, req) + var resp struct { + Total int `json:"total"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Total != 0 { + t.Fatalf("public:false paste leaked into /api/public: total=%d", resp.Total) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index e66ed3f..6c6008c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -30,6 +30,8 @@ type Paste struct { BurnAfterRead bool `json:"burn_after_read,omitempty"` BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1) Visibility string `json:"visibility"` + // #83: accept "public": true/false as an alias for visibility. + Public *bool `json:"public,omitempty"` CanID *string `json:"can_id,omitempty"` CreatedAt int64 `json:"created_at"` DeletedAt *int64 `json:"deleted_at,omitempty"` @@ -215,6 +217,14 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { } visibility := p.Visibility + // #83: "public": false -> unlisted, true -> public; overrides string field + if p.Public != nil { + if *p.Public { + visibility = "public" + } else { + visibility = "unlisted" + } + } if visibility == "" { visibility = "public" }