feat: Erinnerung zum Sichern von Story-Insights vor Ablauf
Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.
- Migration 0007: asset.purpose ('initial' | 'insights', Default
'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
damit ein späterer Insights-Upload nie den beim Archivieren
referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
ungetestete gegen echte Instagram-Daten, aber isoliert testbare
Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
"story"-Beiträgen ohne existierendes insights-Asset; POST
/beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
Validierung wie das initiale Standbild, wiederverwendet über
readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
CLAUDE.md-Hinweis dazu.
Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
This commit is contained in:
132
internal/web/insights_handlers_test.go
Normal file
132
internal/web/insights_handlers_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch.
|
||||
func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreatePart(map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`},
|
||||
"Content-Type": {"image/png"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
if _, err := part.Write(imageBytes); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("multipart Close: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) {
|
||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
|
||||
|
||||
subID := checkAndReturnSubID2(t, s, cookie, "story")
|
||||
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
||||
if archiveResp.Code != http.StatusOK {
|
||||
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
|
||||
}
|
||||
|
||||
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||
if detailResp.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||
}
|
||||
body := detailResp.Body.String()
|
||||
if !strings.Contains(body, "Insights jetzt sichern") {
|
||||
t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body)
|
||||
}
|
||||
_ = fs
|
||||
}
|
||||
|
||||
func TestInsightsUploadClearsReminder(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
|
||||
|
||||
subID := checkAndReturnSubID2(t, s, cookie, "story")
|
||||
if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK {
|
||||
t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG)
|
||||
if uploadResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String())
|
||||
}
|
||||
|
||||
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||
body := detailResp.Body.String()
|
||||
if strings.Contains(body, "Insights jetzt sichern") {
|
||||
t.Errorf("expected the reminder to be gone after securing insights, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Gesicherte Insights-Nachweise") {
|
||||
t.Errorf("expected the secured insights section, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsightsUploadRejectsForeignSubmission(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
subID := checkAndReturnSubID2(t, s, cookieA, "story")
|
||||
|
||||
resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code)
|
||||
}
|
||||
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
|
||||
t.Fatal("expected no asset from a rejected cross-tenant upload")
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type.
|
||||
func checkAndReturnSubID2(t *testing.T, s interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
}, cookie *http.Cookie, postType string) string {
|
||||
t.Helper()
|
||||
form := checkForm("post_type", postType)
|
||||
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
const marker = `name="submission_id" value="`
|
||||
idx := strings.Index(body, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected a submission_id field in the result, got: %s", body)
|
||||
}
|
||||
rest := body[idx+len(marker):]
|
||||
return rest[:strings.Index(rest, `"`)]
|
||||
}
|
||||
Reference in New Issue
Block a user