mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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:
parent
95aafb7136
commit
7bd39d2a93
2 changed files with 249 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
167
packages/content-fetch-go/internal/handler/handler_test.go
Normal file
167
packages/content-fetch-go/internal/handler/handler_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue