Skip to content

courseRun test factory generates chronologically impossible runs (~49% end before they start) #3698

Description

@blarghmatey

Expected Behavior

The courseRun test factory should produce runs whose dates are internally coherent: end_date after start_date, and certificate_available_date at or after end_date. A test that doesn't care about dates should still get a plausible run, so that any test rendering or querying a date range gets a deterministic, sane label.

Current Behavior

Every date field is an independent faker draw, so the ordering between them is random:

title: faker.lorem.words(3),
start_date: faker.date.future().toISOString(),
end_date: faker.date.future().toISOString(),
enrollment_start: faker.date.past().toISOString(),
enrollment_end: faker.date.future().toISOString(),
courseware_url: faker.internet.url(),
courseware_id: faker.string.uuid(),
certificate_available_date: faker.date.future().toISOString(),

start_date: faker.date.future().toISOString(),
end_date: faker.date.future().toISOString(),
enrollment_start: faker.date.past().toISOString(),
enrollment_end: faker.date.future().toISOString(),
certificate_available_date: faker.date.future().toISOString(),
upgrade_deadline: faker.date.future().toISOString(),

start_date and end_date are two independent draws from the same window, so they are ordered correctly only by chance. Measured over 2000 generated runs:

end_date < start_date:                     49.3%
certificate_available_date < end_date:     50.1%
enrollment_end < enrollment_start:          0.0%

So roughly half of all generated course runs end before they start, and half advertise a certificate before the run finishes. (enrollment_start/enrollment_end are fine — past() vs future() are ordered by construction.)

Beyond being nonsense data, this actively causes random CI failures. Session options are labelled with the whole range ("Aug 28 - Sep 27, 2026"), so tests that select an option by a regex built from one run's start date can match a second option whenever another run's random end_date lands on that same calendar day:

TestingLibraryElementError: Found multiple elements with the role "option" and name `/Sep 27/i`
  <li aria-selected="false" ...>Sep 27 - Nov 3, 2026 (no certificate available)</li>
  <li aria-selected="true"  ...>Aug 28 - Sep 27, 2026 (no certificate available) — Enrolled</li>

That took down javascript-tests on an unrelated PR: https://github.com/mitodl/mit-learn/actions/runs/30469814580. Because the draw is random, the failure lands on whichever PR happens to be unlucky, and the PR it lands on is never the cause.

Steps to Reproduce

To reproduce the incoherent data directly, add a scratch test anywhere under frontends/main/src and run it with NODE_ENV=test yarn jest <path>:

import { factories } from "api/mitxonline-test-utils"

test("measure factory date incoherence", () => {
  const N = 2000
  let endBeforeStart = 0
  for (let i = 0; i < N; i++) {
    const r = factories.courses.courseRun()
    if (new Date(r.end_date!) < new Date(r.start_date!)) endBeforeStart++
  }
  console.log(`end_date < start_date: ${((endBeforeStart / N) * 100).toFixed(1)}%`)
})

To reproduce the resulting flake deterministically, render SessionSelect with one run whose end_date equals the next run's start_date:

const days = (n: number) => new Date(Date.now() + n * 864e5).toISOString()
const a = makeRun({ start_date: days(30), end_date: days(60) })
const b = makeRun({ start_date: days(60), end_date: days(75) })
// ... open the combobox, then:
screen.getByRole("option", { name: new RegExp(formatDate(b.start_date!, "MMM D"), "i") })
// throws: Found multiple elements

Possible Solution

Derive the dependent dates from start_date instead of drawing them independently, so the ordering holds by construction:

const start = faker.date.future()
const end = faker.date.soon({ days: 120, refDate: start })
// ...
start_date: start.toISOString(),
end_date: end.toISOString(),
certificate_available_date: faker.date.soon({ days: 30, refDate: end }).toISOString(),
upgrade_deadline: faker.date.between({ from: start, to: end }).toISOString(),

This doesn't eliminate the label-collision class of flake on its own (two runs could still coincidentally share a boundary date), but it removes the nonsense data and makes collisions far less likely, since ranges no longer overlap arbitrarily.

Additional Details

Scope check before anyone picks this up: courseRun is used across 17 test files and ~230 call sites, of which only ~69 pin end_date explicitly. So ~160 call sites currently receive a random — and about half the time, backwards — date range. Changing the defaults will shift fixture data under all of them, which is why #3697 deliberately fixed only the two affected tests rather than the factory: the blast radius of a factory change is much larger than that flake justified.

That makes this worth doing as its own change, with a full yarn test run to catch any test that was implicitly depending on the current (incoherent) ordering.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions