//go:build integration package integration import ( "context" "fmt" "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. // backdateRequisition moves a requisition's opened_at so a cohort can span real durations without the // test waiting. Done through the migrator because opened_at is not an application writable field. func backdateRequisition(t *testing.T, org, req uuid.UUID, daysAgo int) { t.Helper() if _, err := migrator.Exec(context.Background(), // make_interval takes an integer directly. Concatenating the number into a string and casting it // forces pgx to encode an int as text, which it refuses, and the failure looks like a driver bug // rather than a query one. `UPDATE requisitions SET opened_at = now() - make_interval(days => $3) WHERE organization_id = $1 AND id = $2`, org, req, daysAgo); err != nil { t.Fatalf("backdate: %v", err) } } // TestAnalyticsCorrectsSurvivorshipBias is the point of this phase. // // Five orders filled quickly and five still open far longer. The naive figure, which every other tool // reports, sees only the five that filled and says the desk is fast. The corrected figure keeps the open // ones in as lower bounds and tells the truth. func TestAnalyticsCorrectsSurvivorshipBias(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Analytics Recruiter") svc := reqSvc() // Five that filled fast: opened 40 days ago, placed 10 days after opening. for i := 0; i < 5; i++ { r, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Fast role", Openings: 1}) if err != nil { t.Fatalf("create: %v", err) } req := uuid.MustParse(r.ID) backdateRequisition(t, org, req, 40) cand := seedPipelineCandidate(t, org, "Fast cand") a, err := svc.Submit(ctx, req, recruiting.CreateApplicationInput{CandidateID: cand.String()}) if err != nil { t.Fatalf("submit: %v", err) } start := time.Now().UTC().AddDate(0, 0, -30).Format("2006-01-02") // 10 days after opening if _, err := svc.CreatePlacement(ctx, uuid.MustParse(a.ID), recruiting.CreatePlacementInput{ EmploymentType: "contract", StartDate: start, BillRate: "150", PayRate: "100"}); err != nil { t.Fatalf("place: %v", err) } } // Seven opened 80 days ago and still open, so each is known to have taken at least 80 days. // // Seven rather than five deliberately. With an even five and five, survival falls to exactly one half // at the fill time and 9.83 days IS the correct median, so that cohort demonstrates nothing. With // fewer than half filled the curve never reaches one half at all, and the corrected answer becomes // the honest one: not yet known. The naive figure still cheerfully reports ten days. for i := 0; i < 7; i++ { r, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Stuck role", Openings: 1}) if err != nil { t.Fatalf("create: %v", err) } backdateRequisition(t, org, uuid.MustParse(r.ID), 80) } v, err := svc.Analytics(ctx, time.Now().UTC().AddDate(0, 0, -120).Format(time.RFC3339)) if err != nil { t.Fatalf("analytics: %v", err) } ttf := v.TimeToStart if ttf.Cohort != 12 || ttf.FilledN != 5 || ttf.OpenN != 7 { t.Fatalf("cohort accounting: %+v", ttf) } // The naive figure sees only the fast five and reports about ten days. if ttf.NaiveMedian == "" { t.Fatal("no naive median was computed") } naive := analyticsFloat(t, ttf.NaiveMedian) if naive > 12 { t.Errorf("naive median = %s days, expected roughly 10 from the fast cohort", ttf.NaiveMedian) } // The corrected figure keeps the seven stuck orders in the calculation. Only five of twelve have // filled, so survival never falls to one half and there is no median yet. Reporting the naive ten // days here would be the fabrication this whole phase exists to prevent. if ttf.MedianReached { t.Errorf("a median of %s was reported when only %d of %d orders have filled", ttf.Median, ttf.FilledN, ttf.Cohort) } if ttf.Median != "" { t.Errorf("median = %q, want it absent and distinguishable from zero", ttf.Median) } // The still open orders are visible in the report rather than silently dropped, which is what lets a // reader see why the median is unavailable. if v.Aging == nil || v.OldestOpenDays < 75 { t.Errorf("oldest open = %d days, want the 80 day cohort to be visible", v.OldestOpenDays) } // The curve must be a real survival function. if len(ttf.Curve) == 0 { t.Error("no survival curve was produced") } prev := 1.0 for _, p := range ttf.Curve { s := analyticsFloat(t, p.Surviving) if s > prev+1e-9 { t.Errorf("survival rose from %v to %v", prev, s) } prev = s } } // analyticsFloat parses a reported decimal string. Named distinctly because the MAG suite already has a // mustFloat with different semantics. func analyticsFloat(t *testing.T, s string) float64 { t.Helper() var f float64 if _, err := fmt.Sscanf(s, "%g", &f); err != nil { t.Fatalf("value %q is not a number: %v", s, err) } return f } // TestAnalyticsReportsBothTimeToFillClocks. The industry does not agree on whether time to fill ends at // the accepted offer or the start date, so reporting one silently is how two tools describe the same // desk differently. func TestAnalyticsReportsBothClocks(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Two Clocks") svc := reqSvc() r, err := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Both clocks", Openings: 1}) if err != nil { t.Fatalf("create: %v", err) } req := uuid.MustParse(r.ID) backdateRequisition(t, org, req, 30) cand := seedPipelineCandidate(t, org, "Cand") a, _ := svc.Submit(ctx, req, recruiting.CreateApplicationInput{CandidateID: cand.String()}) o, err := svc.ExtendOffer(ctx, uuid.MustParse(a.ID), recruiting.CreateOfferInput{ BillRate: "150", PayRate: "100"}) if err != nil { t.Fatalf("offer: %v", err) } if _, err := svc.RespondToOffer(ctx, uuid.MustParse(o.ID), recruiting.OfferStatusInput{ ExpectedStatus: "extended", Status: "accepted"}); err != nil { t.Fatalf("accept: %v", err) } if _, err := svc.CreatePlacement(ctx, uuid.MustParse(a.ID), recruiting.CreatePlacementInput{ EmploymentType: "contract", StartDate: time.Now().UTC().AddDate(0, 0, 14).Format("2006-01-02"), BillRate: "150", PayRate: "100"}); err != nil { t.Fatalf("place: %v", err) } v, err := svc.Analytics(ctx, time.Now().UTC().AddDate(0, 0, -60).Format(time.RFC3339)) if err != nil { t.Fatalf("analytics: %v", err) } if v.TimeToOffer.FilledN != 1 { t.Errorf("time to offer counted %d filled, want 1", v.TimeToOffer.FilledN) } if v.TimeToStart.FilledN != 1 { t.Errorf("time to start counted %d filled, want 1", v.TimeToStart.FilledN) } // The start is later than the offer, so the second clock must read longer than the first. toOffer := analyticsFloat(t, v.TimeToOffer.Median) toStart := analyticsFloat(t, v.TimeToStart.Median) if toStart <= toOffer { t.Errorf("time to start %v is not after time to offer %v", toStart, toOffer) } if v.TimeZone == "" { t.Error("the report does not say which timezone its days are counted in") } } // TestAnalyticsSuppressesMeaninglessRates. One filled requisition is a hundred percent fill rate and it // means nothing. Counts are always shown; only the ratio is withheld. func TestAnalyticsSuppressesMeaninglessRates(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Tiny Cohort") svc := reqSvc() r, _ := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Only one", Openings: 1}) rr, _ := svc.GetRequisition(ctx, uuid.MustParse(r.ID)) if _, err := svc.ChangeStatus(ctx, uuid.MustParse(r.ID), recruiting.ChangeStatusInput{ ExpectedUpdatedAt: rr.UpdatedAt, Status: "filled"}); err != nil { t.Fatalf("fill: %v", err) } v, err := svc.Analytics(ctx, time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)) if err != nil { t.Fatalf("analytics: %v", err) } if !v.FillRate.Suppressed { t.Error("a fill rate was reported for a cohort of one") } if v.FillRate.OfResolved != "" || v.FillRate.OfDecided != "" { t.Errorf("rates leaked despite suppression: %+v", v.FillRate) } if v.FillRate.Filled != 1 { t.Error("suppressing the rate must not suppress the counts") } } // TestAnalyticsEmptyOrgIsComplete. A new client's first login must render the whole report as zeros // rather than a crash, a divide by zero, or a missing section. func TestAnalyticsEmptyOrgIsComplete(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Brand New") svc := reqSvc() v, err := svc.Analytics(ctx, "") if err != nil { t.Fatalf("analytics on an empty org: %v", err) } if v.TimeToOffer.Cohort != 0 || v.TimeToStart.MedianReached { t.Errorf("an empty org reported a time to fill: %+v", v.TimeToStart) } if v.Funnel == nil || v.Aging == nil || v.Sources == nil || v.Recruiters == nil || v.Conversion == nil { t.Error("report slices must be empty, never nil, so the JSON is [] and not null") } if len(v.Funnel) != 6 { t.Errorf("funnel has %d rungs on an empty org, want 6 so the chart shape is stable", len(v.Funnel)) } if len(v.Aging) != 5 { t.Errorf("aging has %d bands, want 5", len(v.Aging)) } if v.TimeZone == "" { t.Error("no timezone reported") } } // TestAnalyticsFunnelIsMonotoneOnRealData. The pure test proves the arithmetic; this proves the query // feeding it yields marks with the same property. func TestAnalyticsFunnelIsMonotone(t *testing.T) { org := seedOrg(t) _, ctx := seedRecruiter(t, org, "Funnel Owner") svc := reqSvc() r, _ := svc.CreateRequisition(ctx, recruiting.CreateRequisitionInput{Title: "Funnel role", Openings: 6}) req := uuid.MustParse(r.ID) ladder := []string{"screened", "submitted", "interviewing", "offered", "placed"} for i := 0; i < 6; i++ { cand := seedPipelineCandidate(t, org, "C") a, err := svc.Submit(ctx, req, recruiting.CreateApplicationInput{ CandidateID: cand.String(), SourceTouch: "referral"}) if err != nil { t.Fatalf("submit: %v", err) } cur := "sourced" for _, step := range ladder[:i] { if _, err := svc.MoveStage(ctx, uuid.MustParse(a.ID), recruiting.MoveStageInput{ ExpectedStage: cur, ToStage: step}); err != nil { t.Fatalf("advance: %v", err) } cur = step } } v, err := svc.Analytics(ctx, time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)) if err != nil { t.Fatalf("analytics: %v", err) } for i := 0; i+1 < len(v.Funnel); i++ { if v.Funnel[i+1].Count > v.Funnel[i].Count { t.Errorf("funnel widened: %s=%d then %s=%d", v.Funnel[i].Stage, v.Funnel[i].Count, v.Funnel[i+1].Stage, v.Funnel[i+1].Count) } } if v.Funnel[0].Count != 6 { t.Errorf("first rung = %d, want 6", v.Funnel[0].Count) } // Source effectiveness is attributed on first touch. found := false for _, s := range v.Sources { if s.Source == "referral" { found = true if s.Applications != 6 { t.Errorf("referral shows %d applications, want 6", s.Applications) } } } if !found { t.Error("the referral source did not appear in source effectiveness") } } // TestAnalyticsTenantIsolation, with a control. func TestAnalyticsTenantIsolation(t *testing.T) { orgA := seedOrg(t) _, ctxA := seedRecruiter(t, orgA, "Tenant A") svc := reqSvc() for i := 0; i < 3; i++ { if _, err := svc.CreateRequisition(ctxA, recruiting.CreateRequisitionInput{Title: "A role"}); err != nil { t.Fatalf("create in A: %v", err) } } orgB := seedOrg(t) _, ctxB := seedRecruiter(t, orgB, "Tenant B") vB, err := svc.Analytics(ctxB, time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)) if err != nil { t.Fatalf("analytics in B: %v", err) } if vB.TimeToStart.Cohort != 0 { t.Fatalf("tenant B's report counts %d requisitions it does not own", vB.TimeToStart.Cohort) } // Control: A sees its own three. vA, err := svc.Analytics(ctxA, time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)) if err != nil { t.Fatalf("analytics in A: %v", err) } if vA.TimeToStart.Cohort != 3 { t.Errorf("tenant A's cohort = %d, want 3", vA.TimeToStart.Cohort) } }