//go:build integration package integration import ( "testing" "time" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/modules/recruiting" ) // WARNING. Point MM_APP_DSN and MM_MIGRATOR_DSN at a scratch or staging database, never at production. func strPtr(s string) *string { return &s } // TestTaskCanBeClosedReopenedAndCancelled. Reopening was impossible before 0057 because completed_at was // never cleared and the CHECK refused the row. func TestTaskCanBeClosedReopenedAndCancelled(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Queue Owner") svc := reqSvc() task, err := svc.CreateTask(ctx, recruiting.CreateTaskInput{ Title: "Call the hiring manager", DueAt: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339)}) if err != nil { t.Fatalf("create: %v", err) } if task.AssignedTo == "" { t.Error("a task created with no assignee was not defaulted to its creator") } id := uuid.MustParse(task.ID) done, err := svc.UpdateTask(ctx, id, recruiting.UpdateTaskInput{ ExpectedUpdatedAt: task.UpdatedAt, Status: strPtr("done")}) if err != nil { t.Fatalf("close: %v", err) } if done.CompletedAt == "" { t.Error("closing a task did not stamp completed_at") } if done.Bucket != "none" { t.Errorf("a closed task is in bucket %q, want none: a completed task must never read as overdue", done.Bucket) } reopened, err := svc.UpdateTask(ctx, id, recruiting.UpdateTaskInput{ ExpectedUpdatedAt: done.UpdatedAt, Status: strPtr("open")}) if err != nil { t.Fatalf("reopen: %v", err) } if reopened.CompletedAt != "" { t.Error("reopening left a completion time, so the task still reads as finished") } cancelled, err := svc.UpdateTask(ctx, id, recruiting.UpdateTaskInput{ ExpectedUpdatedAt: reopened.UpdatedAt, Status: strPtr("cancelled")}) if err != nil { t.Fatalf("cancel: %v", err) } if cancelled.CompletedAt != "" { t.Error("a cancelled task carries a completion time") } } // TestQueueBucketsUseTheOrganizationDay. This is the defect with the widest blast radius: an evening // task in a US timezone is the next UTC day, so a UTC bucket would call today's work tomorrow's. func TestQueueBucketsUseTheOrganizationDay(t *testing.T) { org := seedOrg(t) // The timezone is an owner setting, so this must act as one. Seeding a recruiter here is what the // service now correctly refuses. _, ctx := seedRoleUser(t, org, "TZ Owner", "owner") svc := reqSvc() if _, err := svc.SetTimeZone(ctx, "America/Chicago"); err != nil { t.Fatalf("set time zone: %v", err) } // 23:30 Chicago today is already tomorrow in UTC. Bucketed on the organization's day it is today. loc, err := time.LoadLocation("America/Chicago") if err != nil { t.Skipf("tz database unavailable: %v", err) } nowLocal := time.Now().In(loc) lateToday := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 23, 30, 0, 0, loc) if lateToday.Before(time.Now()) { // Already past 23:30 locally, so use tomorrow and assert the soon bucket instead. lateToday = lateToday.Add(24 * time.Hour) } task, err := svc.CreateTask(ctx, recruiting.CreateTaskInput{ Title: "Late in the day", DueAt: lateToday.Format(time.RFC3339)}) if err != nil { t.Fatalf("create: %v", err) } wantBucket := "today" if lateToday.In(loc).Day() != nowLocal.Day() { wantBucket = "soon" } if task.Bucket != wantBucket { t.Errorf("a task due at %s bucketed as %q, want %q in America/Chicago", lateToday.Format(time.RFC3339), task.Bucket, wantBucket) } q, err := svc.TaskQueueView(ctx, "", false) if err != nil { t.Fatalf("queue: %v", err) } if q.TimeZone != "America/Chicago" { t.Errorf("queue reports timezone %q, want America/Chicago", q.TimeZone) } // An invalid zone must be refused rather than silently stored and then failing every later query. if _, err := svc.SetTimeZone(ctx, "Mars/Olympus"); err == nil { t.Error("an invalid IANA time zone was accepted") } } // TestQueueCountsAgreeWithTheRowsBelowThem. A header that contradicts its own list is worse than no // header, which is why the counts are derived from the returned rows rather than a second query. func TestQueueCountsAgreeWithTheRows(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Counter") svc := reqSvc() now := time.Now().UTC() for _, d := range []time.Duration{-72 * time.Hour, -1 * time.Hour, 30 * time.Hour, 96 * time.Hour, 800 * time.Hour} { if _, err := svc.CreateTask(ctx, recruiting.CreateTaskInput{ Title: "Task " + d.String(), DueAt: now.Add(d).Format(time.RFC3339)}); err != nil { t.Fatalf("create: %v", err) } } if _, err := svc.CreateTask(ctx, recruiting.CreateTaskInput{Title: "No due date"}); err != nil { t.Fatalf("create undated: %v", err) } q, err := svc.TaskQueueView(ctx, "", false) if err != nil { t.Fatalf("queue: %v", err) } counted := map[string]int{} for _, task := range q.Tasks { counted[task.Bucket]++ } if q.Overdue != counted["overdue"] { t.Errorf("header says %d overdue, the list holds %d", q.Overdue, counted["overdue"]) } if q.Today != counted["today"] { t.Errorf("header says %d today, the list holds %d", q.Today, counted["today"]) } if q.Soon != counted["soon"] { t.Errorf("header says %d soon, the list holds %d", q.Soon, counted["soon"]) } if q.Later != counted["later"] { t.Errorf("header says %d later, the list holds %d", q.Later, counted["later"]) } if q.NoDueDate != counted["none"] { t.Errorf("header says %d undated, the list holds %d", q.NoDueDate, counted["none"]) } if len(q.Tasks) != 6 { t.Errorf("%d tasks in the queue, want 6", len(q.Tasks)) } // Overdue must sort first, which is the whole point of the queue. if len(q.Tasks) > 0 && q.Tasks[0].Bucket != "overdue" { t.Errorf("the first task is %q, want the oldest overdue one first", q.Tasks[0].Bucket) } } // TestTasksSurviveTheirAssignee. A task is work that still needs doing, so losing it when someone leaves // loses the work rather than the record of it. func TestTasksSurviveTheirAssignee(t *testing.T) { org := seedOrg(t) userID, ctx := seedRecruiter(t, org, "Departing Owner") svc := reqSvc() if _, err := svc.CreateTask(ctx, recruiting.CreateTaskInput{ Title: "Chase the signed contract", AssignedTo: userID.String()}); err != nil { t.Fatalf("create: %v", err) } if _, err := migrator.Exec(ctxOrg(org), `DELETE FROM users WHERE id = $1`, userID); err != nil { t.Fatalf("delete the user: %v", err) } // The context's user is gone, so query as another member of the same tenant. _, ctx2 := seedRecruiter(t, org, "Successor") q, err := svc.TaskQueueView(ctx2, "unassigned", false) if err != nil { t.Fatalf("queue: %v", err) } if len(q.Tasks) != 1 { t.Fatalf("%d unassigned tasks after the owner left, want 1", len(q.Tasks)) } if q.Tasks[0].AssignedTo != "" { t.Error("the surviving task still names a deleted user") } if q.Unassigned != 1 { t.Errorf("unassigned count = %d, want 1", q.Unassigned) } if q.Tasks[0].Title != "Chase the signed contract" { t.Errorf("the surviving task is %q", q.Tasks[0].Title) } } // TestTaskOrderingIsDeterministic. Without a total order, two tasks sharing a due date can swap between // pages so one is shown twice and another never. func TestTaskOrderingIsDeterministic(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Pager") svc := reqSvc() same := time.Now().UTC().Add(48 * time.Hour).Format(time.RFC3339) for i := 0; i < 12; i++ { if _, err := svc.CreateTask(ctx, recruiting.CreateTaskInput{ Title: "Same due date", DueAt: same}); err != nil { t.Fatalf("create: %v", err) } } var first []string for run := 0; run < 4; run++ { list, err := svc.ListTasks(ctx, "", "", "", 100, 0) if err != nil { t.Fatalf("list: %v", err) } ids := make([]string, len(list.Tasks)) for i, x := range list.Tasks { ids[i] = x.ID } if run == 0 { first = ids continue } for i := range ids { if ids[i] != first[i] { t.Fatalf("ordering changed between identical queries at position %d", i) } } } // And paging covers every row exactly once. seen := map[string]bool{} for offset := 0; offset < 12; offset += 5 { page, err := svc.ListTasks(ctx, "", "", "", 5, offset) if err != nil { t.Fatalf("page at %d: %v", offset, err) } for _, x := range page.Tasks { if seen[x.ID] { t.Fatalf("task %s appeared on two pages", x.ID) } seen[x.ID] = true } } if len(seen) != 12 { t.Errorf("paging covered %d of 12 tasks", len(seen)) } } // TestTaskTenantIsolation, with a control. func TestTaskTenantIsolation(t *testing.T) { orgA := seedOrg(t) _, ctxA := seedRoleUser(t, orgA, "Tenant A", "owner") svc := reqSvc() if _, err := svc.CreateTask(ctxA, recruiting.CreateTaskInput{Title: "Tenant A private task"}); err != nil { t.Fatalf("create in A: %v", err) } orgB := seedOrg(t) _, ctxB := seedRoleUser(t, orgB, "Tenant B", "owner") qB, err := svc.TaskQueueView(ctxB, "", false) if err != nil { t.Fatalf("queue in B: %v", err) } for _, x := range qB.Tasks { if x.Title == "Tenant A private task" { t.Fatal("tenant B can see tenant A's task") } } // Each tenant carries its own working day. if _, err := svc.SetTimeZone(ctxA, "America/Los_Angeles"); err != nil { t.Fatalf("set tz in A: %v", err) } qA, _ := svc.TaskQueueView(ctxA, "", false) qB2, _ := svc.TaskQueueView(ctxB, "", false) if qA.TimeZone != "America/Los_Angeles" { t.Errorf("tenant A timezone = %q", qA.TimeZone) } if qB2.TimeZone == "America/Los_Angeles" { t.Error("changing tenant A's timezone changed tenant B's working day") } // Control: A still sees its own. if len(qA.Tasks) != 1 { t.Errorf("tenant A sees %d of its own tasks, want 1", len(qA.Tasks)) } }