diff --git a/internal/api/viewdedupe_test.go b/internal/api/viewdedupe_test.go new file mode 100644 index 0000000..ddc7dde --- /dev/null +++ b/internal/api/viewdedupe_test.go @@ -0,0 +1,121 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "palette/internal/store" +) + +// #95: HTML paste views dedupe per viewer (vwr cookie) within the burn viewer +// window, reusing the paste_views table. Repeated views by the same viewer do +// not inflate view_count; a different viewer counts. +func TestHTMLViewsDedupedPerViewer(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vd"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + id := jsonField(t, rec.Body.String(), "id") + + view := func(vwr string) { + req := httptest.NewRequest("GET", "/"+id, nil) + if vwr != "" { + req.AddCookie(&http.Cookie{Name: "vwr", Value: vwr}) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("view: got %d", rec.Code) + } + } + count := func() string { + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Body.String() + } + + // same viewer (same cookie) renders twice: still 1 view + view("v-a") + view("v-a") + if got := count(); !strings.Contains(got, `"view_count":1`) { + t.Fatalf("expected view_count 1 after duplicate viewer views, got: %s", got) + } + // a different viewer counts + view("v-b") + if got := count(); !strings.Contains(got, `"view_count":2`) { + t.Fatalf("expected view_count 2 after second viewer, got: %s", got) + } + // viewer without cookie counts every time (plain API clients, #49 decision) + view("") + view("") + if got := count(); !strings.Contains(got, `"view_count":4`) { + t.Fatalf("expected view_count 4 after uncoookied views, got: %s", got) + } +} + +// #95: raw views always count, even from a cookie-holding viewer (#49 decision). +func TestRawViewsAlwaysCount(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"rv"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + id := jsonField(t, rec.Body.String(), "id") + + for i := 0; i < 3; i++ { + req := httptest.NewRequest("GET", "/raw/"+id, nil) + req.AddCookie(&http.Cookie{Name: "vwr", Value: "same-viewer"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("raw view: got %d", rec.Code) + } + } + req = httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got := rec.Body.String(); !strings.Contains(got, `"view_count":3`) { + t.Fatalf("expected view_count 3 after 3 raw views by one viewer, got: %s", got) + } +} + +// #95: after the dedupe window passes, the same viewer counts again. +func TestViewDedupeWindowExpires(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"we"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + id := jsonField(t, rec.Body.String(), "id") + + view := func() { + req := httptest.NewRequest("GET", "/"+id, nil) + req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-w"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("view: got %d", rec.Code) + } + } + view() + view() + // jump past the 15-minute window + realNow := store.TimeNow + store.TimeNow = func() time.Time { return realNow().Add(16 * time.Minute) } + defer func() { store.TimeNow = realNow }() + view() + req = httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got := rec.Body.String(); !strings.Contains(got, `"view_count":2`) { + t.Fatalf("expected view_count 2 after window expiry, got: %s", got) + } +} diff --git a/internal/web/version.go b/internal/web/version.go new file mode 100644 index 0000000..67931a9 --- /dev/null +++ b/internal/web/version.go @@ -0,0 +1,6 @@ +package web + +// Version is the display version, set at build time via +// -ldflags "-X palette/internal/web.Version=v0.2.1" (#93). Falls back to +// "dev" for bare `go build` runs. +var Version = "dev"