From 7bd39d2a93beb26d5c7454920920bae39a43ac3d Mon Sep 17 00:00:00 2001 From: Aliaksei Karneyeu Date: Fri, 6 Mar 2026 11:44:50 +0100 Subject: [PATCH] Add tests for labels-as-objects fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handler_test.go (unit, no Docker): - TestJobData_UnmarshalLabelsAsObjects — exact payload from refreshFeed.ts - TestJobData_UnmarshalLabelsWithColor — optional color field - TestJobData_UnmarshalNoLabels — absent labels field is valid - TestJobData_UnmarshalMultipleLabels — multiple label objects with all fields - TestSavePageJobData_MarshalLabels — outgoing jobs serialise labels as objects integration_test.go (Redis container): - TestIntegration_RSSJobWithLabelObjects — enqueues a job with labels:[{"name":"RSS"}] through the full worker pipeline and asserts the resulting save-page job carries the label object through to the backend queue Co-Authored-By: Claude Opus 4.6 --- packages/content-fetch-go/integration_test.go | 82 +++++++++ .../internal/handler/handler_test.go | 167 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 packages/content-fetch-go/internal/handler/handler_test.go diff --git a/packages/content-fetch-go/integration_test.go b/packages/content-fetch-go/integration_test.go index 8f663510f..8637d7bad 100644 --- a/packages/content-fetch-go/integration_test.go +++ b/packages/content-fetch-go/integration_test.go @@ -862,5 +862,87 @@ func TestIntegration_SetMethodNotAllowed(t *testing.T) { } } +// TestIntegration_RSSJobWithLabelObjects verifies the full path for an RSS-style +// job whose labels field contains objects ({"name":"RSS"}) rather than strings. +// This is the exact shape produced by refreshFeed.ts in the queue-processor. +// Before the fix, the worker logged: +// +// json: cannot unmarshal object into Go struct field JobData.labels of type string +func TestIntegration_RSSJobWithLabelObjects(t *testing.T) { + env := newTestEnv(t) + defer env.close() + + const ( + userID = "rss-user-1" + itemID = "rss-item-1" + targetURL = "https://example.com/rss-article" + feedURL = "https://example.com/feed.xml" + ) + + // Pre-seed a cache entry so no real browser is launched. + seedCacheEntry(t, env, targetURL, "", "", &fetch.Result{ + FinalURL: targetURL, + Title: "RSS Article", + Content: "rss content", + ContentType: "text/html", + }) + + // Build the job payload exactly as the queue-processor does it: + // labels: [{ name: 'RSS' }] ← objects, not strings + rssJob := map[string]interface{}{ + "url": targetURL, + "users": []map[string]string{{"id": userID, "libraryItemId": itemID}}, + "priority": "low", + "labels": []map[string]string{{"name": "RSS"}}, + "rssFeedUrl": feedURL, + "savedAt": "2026-01-20T00:00:00.000Z", + "publishedAt": "2026-01-20T00:00:00.000Z", + "source": "rss-feeder", + } + + ctx := context.Background() + if err := bullmq.AddBulk(ctx, env.redisDS.MQClient, bullmq.ContentFetchQueue, []bullmq.AddJobOpts{ + { + Name: "fetch-content", + Data: rssJob, + Opts: bullmq.JobOpts{Attempts: 2, Priority: 10, + Backoff: bullmq.BackoffOpt{Type: "exponential", Delay: 2000}}, + }, + }); err != nil { + t.Fatalf("AddBulk error: %v", err) + } + + // Start the worker — it must not fail to unmarshal the job. + workerCtx, workerCancel := context.WithCancel(context.Background()) + defer workerCancel() + w := newTestWorker(workerCtx, env) + w.Start() + + // A save-page job for userID should appear in the backend queue. + saveJobBytes := waitForSavePageJob(t, env, userID, 15*time.Second) + + var saveJob map[string]interface{} + if err := json.Unmarshal(saveJobBytes, &saveJob); err != nil { + t.Fatalf("parse save-page job: %v", err) + } + + assertField(t, saveJob, "userId", userID) + assertField(t, saveJob, "url", targetURL) + assertField(t, saveJob, "source", "rss-feeder") + + // The save-page job must propagate labels as objects too. + rawLabels, ok := saveJob["labels"].([]interface{}) + if !ok || len(rawLabels) == 0 { + t.Fatalf("expected labels array in save-page job, got: %v", saveJob["labels"]) + } + firstLabel, ok := rawLabels[0].(map[string]interface{}) + if !ok { + t.Fatalf("expected label object, got %T", rawLabels[0]) + } + if firstLabel["name"] != "RSS" { + t.Errorf("label name: got %v, want %q", firstLabel["name"], "RSS") + } +} + // Ensure the redis client type used in tests is compatible. var _ *redis.Client = (*redis.Client)(nil) diff --git a/packages/content-fetch-go/internal/handler/handler_test.go b/packages/content-fetch-go/internal/handler/handler_test.go new file mode 100644 index 000000000..86108a597 --- /dev/null +++ b/packages/content-fetch-go/internal/handler/handler_test.go @@ -0,0 +1,167 @@ +package handler + +import ( + "encoding/json" + "testing" +) + +// TestJobData_UnmarshalLabelsAsObjects verifies that the labels field is correctly +// decoded when the queue-processor sends label objects like {"name":"RSS"} rather +// than plain strings. This is the exact payload shape produced by refreshFeed.ts: +// +// labels: [{ name: 'RSS' }] +func TestJobData_UnmarshalLabelsAsObjects(t *testing.T) { + raw := `{ + "url": "https://example.com/article", + "users": [{"id": "user-1", "libraryItemId": "item-1"}], + "priority": "low", + "labels": [{"name": "RSS"}], + "rssFeedUrl": "https://example.com/feed.xml", + "savedAt": "2026-01-20T00:00:00.000Z", + "publishedAt": "2026-01-20T00:00:00.000Z", + "source": "rss-feeder" + }` + + var data JobData + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal error (was labels declared as []string instead of []LabelInput?): %v", err) + } + + if len(data.Labels) != 1 { + t.Fatalf("expected 1 label, got %d", len(data.Labels)) + } + if data.Labels[0].Name != "RSS" { + t.Errorf("expected label name 'RSS', got %q", data.Labels[0].Name) + } + if data.Labels[0].Color != nil { + t.Errorf("expected color nil, got %v", data.Labels[0].Color) + } +} + +// TestJobData_UnmarshalLabelsWithColor verifies a label with optional color field. +func TestJobData_UnmarshalLabelsWithColor(t *testing.T) { + color := "#FF0000" + raw := `{ + "url": "https://example.com/article", + "priority": "high", + "labels": [{"name": "Important", "color": "#FF0000"}] + }` + + var data JobData + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if len(data.Labels) != 1 { + t.Fatalf("expected 1 label, got %d", len(data.Labels)) + } + if data.Labels[0].Name != "Important" { + t.Errorf("label name: got %q, want %q", data.Labels[0].Name, "Important") + } + if data.Labels[0].Color == nil || *data.Labels[0].Color != color { + t.Errorf("label color: got %v, want %q", data.Labels[0].Color, color) + } +} + +// TestJobData_UnmarshalNoLabels verifies that omitting labels entirely is valid. +func TestJobData_UnmarshalNoLabels(t *testing.T) { + raw := `{"url": "https://example.com/article", "priority": "high"}` + + var data JobData + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(data.Labels) != 0 { + t.Errorf("expected 0 labels, got %d", len(data.Labels)) + } +} + +// TestJobData_UnmarshalMultipleLabels verifies multiple label objects decode correctly. +func TestJobData_UnmarshalMultipleLabels(t *testing.T) { + raw := `{ + "url": "https://example.com/article", + "priority": "low", + "labels": [ + {"name": "RSS"}, + {"name": "Tech", "color": "#00FF00"}, + {"name": "Reading", "description": "To read later"} + ] + }` + + var data JobData + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(data.Labels) != 3 { + t.Fatalf("expected 3 labels, got %d", len(data.Labels)) + } + + names := []string{"RSS", "Tech", "Reading"} + for i, want := range names { + if data.Labels[i].Name != want { + t.Errorf("label[%d]: got %q, want %q", i, data.Labels[i].Name, want) + } + } + + if data.Labels[2].Description == nil || *data.Labels[2].Description != "To read later" { + t.Errorf("label[2] description: got %v, want %q", data.Labels[2].Description, "To read later") + } +} + +// TestSavePageJobData_MarshalLabels verifies that outgoing save-page jobs +// serialise labels back as objects (not strings), preserving the shape the +// backend queue-processor expects. +func TestSavePageJobData_MarshalLabels(t *testing.T) { + color := "#123456" + job := savePageJobData{ + UserID: "user-1", + URL: "https://example.com", + FinalURL: "https://example.com", + ArticleSavingRequestID: "item-1", + Source: "rss-feeder", + Labels: []LabelInput{ + {Name: "RSS"}, + {Name: "Custom", Color: &color}, + }, + } + + b, err := json.Marshal(job) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var out map[string]interface{} + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("re-unmarshal error: %v", err) + } + + rawLabels, ok := out["labels"].([]interface{}) + if !ok { + t.Fatalf("labels field is not an array: %T", out["labels"]) + } + if len(rawLabels) != 2 { + t.Fatalf("expected 2 labels, got %d", len(rawLabels)) + } + + // Each label must be an object, not a string. + for i, l := range rawLabels { + lmap, ok := l.(map[string]interface{}) + if !ok { + t.Errorf("label[%d] is not an object: %T (%v)", i, l, l) + continue + } + if _, hasName := lmap["name"]; !hasName { + t.Errorf("label[%d] missing 'name' key", i) + } + } + + first := rawLabels[0].(map[string]interface{}) + if first["name"] != "RSS" { + t.Errorf("first label name: got %v, want %q", first["name"], "RSS") + } + + second := rawLabels[1].(map[string]interface{}) + if second["color"] != "#123456" { + t.Errorf("second label color: got %v, want %q", second["color"], "#123456") + } +}