- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
85 lines
2.3 KiB
Go
85 lines
2.3 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
|
)
|
|
|
|
type mockPatcher struct {
|
|
patchedCount int32
|
|
}
|
|
|
|
func (m *mockPatcher) PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error) {
|
|
atomic.AddInt32(&m.patchedCount, 1)
|
|
return &models.FeedEntry{ID: entryID}, &models.EntryDiff{}, nil
|
|
}
|
|
|
|
func (m *mockPatcher) GetEntry(ctx context.Context, id string) (*models.FeedEntry, error) {
|
|
return &models.FeedEntry{ID: id}, nil
|
|
}
|
|
|
|
func TestJobEngineExecution(t *testing.T) {
|
|
tempDBPath := filepath.Join(t.TempDir(), "job_test.db")
|
|
database, err := db.InitDB(tempDBPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to init db: %v", err)
|
|
}
|
|
defer func() { _ = database.Close() }()
|
|
|
|
bus := events.NewEventBus()
|
|
patcher := &mockPatcher{}
|
|
engine := NewJobEngine(database, bus, patcher, 5)
|
|
|
|
var executedCount int32
|
|
engine.RegisterHandler("test_task", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
|
atomic.AddInt32(&executedCount, 1)
|
|
read := true
|
|
return &models.PatchEntryRequest{IsRead: &read}, map[string]interface{}{"done": true}, nil
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
engine.Start(ctx)
|
|
|
|
// Enqueue test job
|
|
job := models.Job{
|
|
Type: "test_task",
|
|
Payload: map[string]interface{}{"entry_id": "e_100"},
|
|
Status: "pending",
|
|
IdempotencyKey: "test_key_1",
|
|
RunAt: time.Now().UTC(),
|
|
}
|
|
created, err := database.CreateJob(job)
|
|
if err != nil {
|
|
t.Fatalf("failed to create job: %v", err)
|
|
}
|
|
|
|
// Wait for engine to claim and execute
|
|
time.Sleep(1 * time.Second)
|
|
|
|
finalJob, err := database.GetJob(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get job: %v", err)
|
|
}
|
|
|
|
if finalJob.Status != "completed" {
|
|
t.Errorf("expected job status 'completed', got '%s'", finalJob.Status)
|
|
}
|
|
if atomic.LoadInt32(&executedCount) != 1 {
|
|
t.Errorf("expected executedCount 1, got %d", atomic.LoadInt32(&executedCount))
|
|
}
|
|
if atomic.LoadInt32(&patcher.patchedCount) != 1 {
|
|
t.Errorf("expected patcher to be called once, got %d", atomic.LoadInt32(&patcher.patchedCount))
|
|
}
|
|
|
|
engine.Stop()
|
|
_ = database.Close()
|
|
}
|