242 lines
6.3 KiB
Go
242 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|