Add tests for labels-as-objects fix

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 <noreply@anthropic.com>
This commit is contained in:
Aliaksei Karneyeu 2026-03-06 11:44:50 +01:00
parent 95aafb7136
commit 7bd39d2a93
2 changed files with 249 additions and 0 deletions

View file

@ -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: "<html><body>rss content</body></html>",
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)

View file

@ -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")
}
}