package tracker import ( "database/sql" "errors" "testing" "time" _ "modernc.org/sqlite" ) func newTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("open: %v", err) } db.SetMaxOpenConns(1) schema := ` CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT, company TEXT, title TEXT, source TEXT, description TEXT, archived INTEGER DEFAULT 0, discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'Evaluated', score REAL, pdf_generated INTEGER DEFAULT 0, applied_at TIMESTAMP, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, archetype TEXT, legitimacy TEXT, evaluated_at TIMESTAMP ); CREATE TABLE follow_ups ( id INTEGER PRIMARY KEY AUTOINCREMENT, application_id INTEGER NOT NULL, follow_up_at TIMESTAMP, contacted_at TIMESTAMP, notes TEXT ); CREATE TABLE tracker_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, application_id INTEGER NOT NULL, from_status TEXT, to_status TEXT NOT NULL, note TEXT, occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ` if _, err := db.Exec(schema); err != nil { t.Fatalf("schema: %v", err) } return db } func seed(t *testing.T, db *sql.DB, status Status) int64 { t.Helper() res, err := db.Exec(`INSERT INTO jobs (url, company, title) VALUES (?, ?, ?)`, "https://example.com/job/1", "Acme", "Engineer") if err != nil { t.Fatalf("seed job: %v", err) } jid, _ := res.LastInsertId() res, err = db.Exec(`INSERT INTO applications (job_id, status) VALUES (?, ?)`, jid, string(status)) if err != nil { t.Fatalf("seed app: %v", err) } aid, _ := res.LastInsertId() return aid } func TestCanAdvance_AllowedAndDenied(t *testing.T) { cases := []struct { from, to Status want bool }{ {StatusEvaluated, StatusApplied, true}, {StatusEvaluated, StatusInterview, false}, {StatusApplied, StatusInterview, true}, {StatusInterview, StatusOffer, true}, {StatusOffer, StatusRejected, false}, // terminal {StatusRejected, StatusApplied, false}, // terminal {StatusEvaluated, StatusEvaluated, false}, // self-loop banned } for _, c := range cases { if got := CanAdvance(c.from, c.to); got != c.want { t.Errorf("CanAdvance(%s,%s)=%v want %v", c.from, c.to, got, c.want) } } } func TestAdvance_WritesEventAndUpdatesStatus(t *testing.T) { db := newTestDB(t) defer db.Close() aid := seed(t, db, StatusEvaluated) mgr := NewManager(db, 0, 7*24*time.Hour) if err := mgr.Advance(aid, StatusApplied, "submitted via portal"); err != nil { t.Fatalf("advance: %v", err) } app, err := mgr.Get(aid) if err != nil { t.Fatal(err) } if app.Status != StatusApplied { t.Errorf("status = %s want %s", app.Status, StatusApplied) } if !app.AppliedAt.Valid { t.Errorf("applied_at not set") } events, err := mgr.Events(aid) if err != nil { t.Fatal(err) } if len(events) != 1 { t.Fatalf("events len = %d want 1", len(events)) } if events[0].To != StatusApplied || events[0].Note.String != "submitted via portal" { t.Errorf("event mismatch: %+v", events[0]) } // Follow-up was scheduled var followCount int _ = db.QueryRow(`SELECT COUNT(*) FROM follow_ups WHERE application_id = ?`, aid).Scan(&followCount) if followCount != 1 { t.Errorf("follow_ups = %d want 1", followCount) } } func TestAdvance_RejectsInvalidTransition(t *testing.T) { db := newTestDB(t) defer db.Close() aid := seed(t, db, StatusEvaluated) mgr := NewManager(db, 0, 0) err := mgr.Advance(aid, StatusOffer, "") if !errors.Is(err, ErrInvalidTransition) { t.Errorf("err = %v want ErrInvalidTransition", err) } } func TestAdvance_RateLimitOnApplied(t *testing.T) { db := newTestDB(t) defer db.Close() mgr := NewManager(db, 2, 0) // cap 2/day for i := 0; i < 2; i++ { aid := seed(t, db, StatusEvaluated) if err := mgr.Advance(aid, StatusApplied, ""); err != nil { t.Fatalf("advance %d: %v", i, err) } } aid := seed(t, db, StatusEvaluated) err := mgr.Advance(aid, StatusApplied, "") if !errors.Is(err, ErrRateLimited) { t.Errorf("err = %v want ErrRateLimited", err) } } func TestAdvance_RateLimitDisabled(t *testing.T) { db := newTestDB(t) defer db.Close() mgr := NewManager(db, 0, 0) // cap disabled for i := 0; i < 5; i++ { aid := seed(t, db, StatusEvaluated) if err := mgr.Advance(aid, StatusApplied, ""); err != nil { t.Fatalf("advance %d: %v", i, err) } } got, _ := mgr.AppliedToday() if got != 5 { t.Errorf("applied today = %d want 5", got) } } func TestAddNote(t *testing.T) { db := newTestDB(t) defer db.Close() aid := seed(t, db, StatusApplied) mgr := NewManager(db, 0, 0) if err := mgr.AddNote(aid, "called recruiter"); err != nil { t.Fatal(err) } events, _ := mgr.Events(aid) if len(events) != 1 || events[0].From.String != string(StatusApplied) || events[0].To != StatusApplied { t.Errorf("note event = %+v", events) } } func TestPendingFollowUps_OnlyDueAndUncontacted(t *testing.T) { db := newTestDB(t) defer db.Close() aid := seed(t, db, StatusApplied) now := time.Now().UTC() _, _ = db.Exec(`INSERT INTO follow_ups (application_id, follow_up_at) VALUES (?, ?)`, aid, now.Add(-1*time.Hour)) // due _, _ = db.Exec(`INSERT INTO follow_ups (application_id, follow_up_at) VALUES (?, ?)`, aid, now.Add(48*time.Hour)) // future res, _ := db.Exec(`INSERT INTO follow_ups (application_id, follow_up_at, contacted_at) VALUES (?, ?, ?)`, aid, now.Add(-2*time.Hour), now.Add(-1*time.Hour)) // already contacted _ = res mgr := NewManager(db, 0, 0) pending, err := mgr.PendingFollowUps() if err != nil { t.Fatal(err) } if len(pending) != 1 { t.Errorf("pending len = %d want 1 (got %+v)", len(pending), pending) } } func TestStats_GroupsByStatus(t *testing.T) { db := newTestDB(t) defer db.Close() seed(t, db, StatusApplied) seed(t, db, StatusApplied) seed(t, db, StatusInterview) seed(t, db, StatusEvaluated) mgr := NewManager(db, 0, 0) s, err := mgr.Stats() if err != nil { t.Fatal(err) } if s.Total != 4 { t.Errorf("total = %d want 4", s.Total) } if s.ByStatus[StatusApplied] != 2 || s.ByStatus[StatusInterview] != 1 || s.ByStatus[StatusEvaluated] != 1 { t.Errorf("byStatus = %+v", s.ByStatus) } }