diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index 209e10413..5d980d908 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -18,6 +18,7 @@ vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({ setTerm: vi.fn(), searchPending: false, filterLocally: true, + period: null, hasOlder: false, hasNewer: false, loadingOlder: false, diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx index 8c7e67de3..21342b4d9 100644 --- a/src/components/links/__tests__/entity-link-picker-paging.test.tsx +++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx @@ -125,6 +125,41 @@ describe("paged link picker", () => { expect(html).toContain('data-slot="labs-add"'); }); + it("names the period a date term searches, and does not filter its answer by the digits", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-period"'); + expect(html).toContain("Showing March 2019"); + expect(html).toContain('data-slot="labs-option"'); + }); + + it("shows no period line for a plain term", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-period"'); + }); + it("does not filter a server answer again by the term", () => { // The route matched on the panel name; the option does not render it. const html = render( diff --git a/src/components/links/__tests__/period-query.test.ts b/src/components/links/__tests__/period-query.test.ts new file mode 100644 index 000000000..ee13a4805 --- /dev/null +++ b/src/components/links/__tests__/period-query.test.ts @@ -0,0 +1,155 @@ +/** + * A picker term that names a year or a month becomes a date window; anything + * else stays a text search. Every accepted form is checked in every UI + * locale, and the near misses that must stay text are checked too. + */ +import { describe, expect, it } from "vitest"; + +import { formatPeriod, parsePeriodQuery } from "../period-query"; + +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const; + +function window(term: string, locale: string) { + const parsed = parsePeriodQuery(term, locale, NOW); + return parsed ? `${parsed.fromDay}..${parsed.toDay}` : null; +} + +const MARCH = "2019-03-01..2019-03-31"; +const YEAR = "2019-01-01..2019-12-31"; + +describe("parsePeriodQuery", () => { + describe.each(LOCALES)("%s", (locale) => { + it("reads a year", () => { + expect(window("2019", locale)).toBe(YEAR); + expect(window(" 2019 ", locale)).toBe(YEAR); + }); + + it.each([ + "03.2019", + "3.2019", + "03/2019", + "3/2019", + "03-2019", + "2019-03", + "2019/03", + "2019.03", + "2019.3", + " 03 . 2019 ", + ])("reads the numeric month %j", (term) => { + expect(window(term, locale)).toBe(MARCH); + }); + + it.each(["LDL", "2019abc", "13.2019", "00.2019", "1850", "1899", "2028"])( + "leaves %j as text", + (term) => { + expect(window(term, locale)).toBeNull(); + }, + ); + + it.each(["", " ", "2019 2020", "03.2019.04", "LDL 2019", "3 2019 x"])( + "leaves %j as text", + (term) => { + expect(window(term, locale)).toBeNull(); + }, + ); + }); + + it.each([ + ["de", ["März 2019", "märz 2019", "MÄRZ 2019", "Maerz 2019", "Marz 2019"]], + ["de", ["mär 2019", "Mär. 2019", "2019 März", "März, 2019"]], + ["en", ["March 2019", "march 2019", "Mar 2019", "Mar. 2019", "2019 March"]], + ["es", ["marzo 2019", "Marzo de 2019", "mar 2019", "mar. 2019"]], + ["fr", ["mars 2019", "Mars 2019", "mar 2019", "2019 mars"]], + ["it", ["marzo 2019", "Marzo 2019", "mar 2019", "marzo del 2019"]], + ["pl", ["marzec 2019", "Marzec 2019", "marca 2019", "mar 2019"]], + ["ko", ["2019년 3월", "2019년3월", "2019년 03월", "3월 2019", "2019. 3."]], + ] as const)("reads March 2019 in %s", (locale, terms) => { + for (const term of terms) { + expect(window(term, locale), term).toBe(MARCH); + } + }); + + it.each([ + ["de", "Dezember 2019", "2019-12-01..2019-12-31"], + ["de", "Okt 2019", "2019-10-01..2019-10-31"], + ["de", "Februar 2020", "2020-02-01..2020-02-29"], + ["en", "Sept 2019", "2019-09-01..2019-09-30"], + ["en", "Sep 2019", "2019-09-01..2019-09-30"], + ["en", "May 2019", "2019-05-01..2019-05-31"], + ["es", "septiembre 2019", "2019-09-01..2019-09-30"], + ["es", "ene 2019", "2019-01-01..2019-01-31"], + ["es", "dic 2019", "2019-12-01..2019-12-31"], + ["fr", "février 2019", "2019-02-01..2019-02-28"], + ["fr", "fevrier 2019", "2019-02-01..2019-02-28"], + ["fr", "févr. 2019", "2019-02-01..2019-02-28"], + ["fr", "août 2019", "2019-08-01..2019-08-31"], + ["fr", "déc 2019", "2019-12-01..2019-12-31"], + ["fr", "juil. 2019", "2019-07-01..2019-07-31"], + ["it", "gennaio 2019", "2019-01-01..2019-01-31"], + ["it", "giu 2019", "2019-06-01..2019-06-30"], + ["it", "set 2019", "2019-09-01..2019-09-30"], + ["pl", "październik 2019", "2019-10-01..2019-10-31"], + ["pl", "paź 2019", "2019-10-01..2019-10-31"], + ["pl", "października 2019", "2019-10-01..2019-10-31"], + ["pl", "luty 2019", "2019-02-01..2019-02-28"], + ["pl", "styczeń 2019", "2019-01-01..2019-01-31"], + ["ko", "2019년 12월", "2019-12-01..2019-12-31"], + ["ko", "2019년", YEAR], + // Austrian German calls January "Jänner", and Intl knows it. + ["de-AT", "Jänner 2019", "2019-01-01..2019-01-31"], + ["de-AT", "März 2019", MARCH], + ] as const)("%s %j", (locale, term, expected) => { + expect(window(term, locale)).toBe(expected); + }); + + it.each([ + // "jui" is both juin and juillet; "ma" is too short to name one month. + ["fr", "jui 2019"], + ["en", "ma 2019"], + ["en", "Ju 2019"], + // A month name from another language is text in this one. + ["en", "März 2019"], + ["de", "March 2019"], + // A month without a year is not a period. + ["en", "March"], + ["de", "03"], + // Two months, or a month name with digits glued on. + ["en", "March May"], + ["en", "March2019"], + ] as const)("%s %j stays text", (locale, term) => { + expect(window(term, locale)).toBeNull(); + }); + + it("accepts next year and refuses the one after", () => { + expect(window("2027", "en")).toBe("2027-01-01..2027-12-31"); + expect(window("12.2027", "en")).toBe("2027-12-01..2027-12-31"); + expect(window("2028", "en")).toBeNull(); + expect(window("1900", "en")).toBe("1900-01-01..1900-12-31"); + }); + + it("names the year and the month", () => { + expect(parsePeriodQuery("2019", "de", NOW)).toEqual({ + year: 2019, + month: null, + fromDay: "2019-01-01", + toDay: "2019-12-31", + }); + expect(parsePeriodQuery("Maerz 2019", "de", NOW)).toMatchObject({ + year: 2019, + month: 3, + }); + }); +}); + +describe("formatPeriod", () => { + it("names a period the way the locale writes it", () => { + const march = parsePeriodQuery("03.2019", "de", NOW)!; + expect(formatPeriod(march, "de")).toBe("März 2019"); + expect(formatPeriod(march, "en")).toBe("March 2019"); + expect(formatPeriod(march, "ko")).toBe("2019년 3월"); + expect(formatPeriod(parsePeriodQuery("2019", "en", NOW)!, "en")).toBe( + "2019", + ); + }); +}); diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts index 125939e4b..377b2a46b 100644 --- a/src/components/links/__tests__/picker-requests.test.ts +++ b/src/components/links/__tests__/picker-requests.test.ts @@ -28,15 +28,24 @@ import { } from "../use-link-picker-pages"; const pivot = pickerPivot("2019-05-10T10:00:00.000Z"); +const ctx = { locale: "en", timeZone: "UTC" } as const; const REQUESTS: PickerPageRequest[] = [ - { direction: "older", pivot, term: "", cursor: null }, - { direction: "older", pivot, term: "", cursor: "200" }, - { direction: "older", pivot: null, term: "", cursor: null }, - { direction: "newer", pivot, term: "", cursor: null }, - { direction: "newer", pivot, term: "", cursor: "100" }, - { direction: "search", pivot: null, term: "ferritin", cursor: null }, - { direction: "search", pivot: null, term: "ferritin", cursor: "100" }, + { direction: "older", pivot, term: "", cursor: null, ...ctx }, + { direction: "older", pivot, term: "", cursor: "200", ...ctx }, + { direction: "older", pivot: null, term: "", cursor: null, ...ctx }, + { direction: "newer", pivot, term: "", cursor: null, ...ctx }, + { direction: "newer", pivot, term: "", cursor: "100", ...ctx }, + { direction: "search", pivot: null, term: "ferritin", cursor: null, ...ctx }, + { direction: "search", pivot: null, term: "ferritin", cursor: "100", ...ctx }, + { direction: "search", pivot: null, term: "2019", cursor: null, ...ctx }, + { + direction: "search", + pivot: null, + term: "March 2019", + cursor: "100", + ...ctx, + }, ]; function params(url: string): Record { @@ -67,10 +76,22 @@ describe("documents picker", () => { it("splits the day-granular window at the pivot's day", () => { const older = params( - documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + documentPickerUrl({ + direction: "older", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); const newer = params( - documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + documentPickerUrl({ + direction: "newer", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); expect(older.to).toBe("2019-05-17"); expect(newer.from).toBe("2019-05-18"); @@ -88,10 +109,22 @@ describe("the windows around a record's date", () => { it("older reads up to the split newest first, newer after it oldest first", () => { const older = params( - labPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + labPickerUrl({ + direction: "older", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); const newer = params( - labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + labPickerUrl({ + direction: "newer", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" }); expect(newer).toMatchObject({ @@ -107,6 +140,7 @@ describe("the windows around a record's date", () => { pivot: null, term: "ferritin", cursor: null, + ...ctx, }), ); expect(search.q).toBe("ferritin"); @@ -120,3 +154,88 @@ describe("the windows around a record's date", () => { expect(nextOffset("100", 3)).toBeNull(); }); }); + +describe("a search term that names a period", () => { + const search = ( + term: string, + locale = "en", + timeZone = "UTC", + ): PickerPageRequest => ({ + direction: "search", + pivot: null, + term, + cursor: null, + locale, + timeZone, + }); + + it.each([ + ["labs", labPickerUrl, "sortDir"], + ["episodes", episodePickerUrl, "sortDir"], + ] as const)( + "%s: a year is the year's instant window, newest first, with no q", + (_name, build, sortKey) => { + const sent = params(build(search("2019"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-01-01T00:00:00.000Z"); + expect(sent.to).toBe("2019-12-31T23:59:59.999Z"); + expect(sent[sortKey]).toBe("desc"); + }, + ); + + it("labs: a month in the person's language is that month", () => { + const sent = params(labPickerUrl(search("März 2019", "de"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-03-01T00:00:00.000Z"); + expect(sent.to).toBe("2019-03-31T23:59:59.999Z"); + }); + + it("the window follows the person's timezone, DST included", () => { + // Berlin: March 2019 starts at UTC+1 and ends at UTC+2. + const sent = params( + episodePickerUrl(search("03.2019", "de", "Europe/Berlin")), + ); + expect(sent.from).toBe("2019-02-28T23:00:00.000Z"); + expect(sent.to).toBe("2019-03-31T21:59:59.999Z"); + }); + + it("documents: the day-granular window is the period's two calendar days", () => { + const sent = params(documentPickerUrl(search("2019-02"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-02-01"); + expect(sent.to).toBe("2019-02-28"); + expect(sent.order).toBe("desc"); + }); + + it("encounters: the route without text search still gets the period", () => { + const sent = params(encounterPickerUrl(search("Mar 2019"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-03-01T00:00:00.000Z"); + expect(sent.to).toBe("2019-03-31T23:59:59.999Z"); + }); + + it.each([labPickerUrl, episodePickerUrl, documentPickerUrl])( + "a plain term stays a text search (%#)", + (build) => { + for (const term of ["LDL", "2019abc", "13.2019", "1850"]) { + const sent = params(build(search(term))); + expect(sent.q).toBe(term); + expect(sent.from).toBeUndefined(); + expect(sent.to).toBeUndefined(); + } + }, + ); + + it("a month name is read in the UI locale only", () => { + // "März" is not an English month, so it stays text. + expect(params(labPickerUrl(search("März 2019", "en"))).q).toBe("März 2019"); + }); + + it("the older and newer windows never read the term", () => { + const older = params( + labPickerUrl({ ...search("2019"), direction: "older", pivot }), + ); + expect(older.to).toBe(pivot); + expect(older.from).toBeUndefined(); + }); +}); diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts index 049c915d8..ef065b4a4 100644 --- a/src/components/links/__tests__/vault-document-options.test.ts +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -113,6 +113,8 @@ describe("fetchVaultPage", () => { pivot: "2026-05-17T23:59:59.999Z", term: "", cursor: "z", + locale: "en", + timeZone: "UTC", }); expect(page.items.map((d) => d.id)).toEqual(["a"]); expect(page.next).toBe("a"); diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts index b9d286a4d..e67f629dd 100644 --- a/tests/integration/visit-link-picker-long-history.test.ts +++ b/tests/integration/visit-link-picker-long-history.test.ts @@ -66,6 +66,8 @@ const DAY_MS = 24 * 60 * 60 * 1000; const VISIT_AT = "2019-04-03T09:30:00.000Z"; const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z"); const RESULT_COUNT = 1_200; +/** What the hook adds to every request: the UI locale and the person's zone. */ +const CTX = { locale: "en", timeZone: "Europe/Berlin" } as const; async function seedOwner() { const prisma = getPrismaClient(); @@ -161,6 +163,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot, term: "", cursor: null, + ...CTX, }); expect(first.results.some((row) => row.id === ferritinId)).toBe(true); // Nothing after the window's split is on it. @@ -182,6 +185,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot, term: "", cursor, + ...CTX, }); for (const row of page.results) { expect(seen.has(row.id)).toBe(false); @@ -207,6 +211,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot: null, term, cursor: null, + ...CTX, }); expect(page.results.map((row) => row.id)).toEqual([ferritinId]); } @@ -240,6 +245,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot: pickerPivot(VISIT_AT), term: "", cursor: null, + ...CTX, }), ); expect(list.past).toHaveLength(1); @@ -276,7 +282,13 @@ describe("the condition picker's window and search", () => { const pivot = pickerPivot(VISIT_AT); const around = await get>( listEpisodes as never, - episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }), + episodePickerUrl({ + direction: "older", + pivot, + term: "", + cursor: null, + ...CTX, + }), ); expect(around[0]!.id).toBe(old.id); @@ -287,6 +299,7 @@ describe("the condition picker's window and search", () => { pivot: null, term: "tendin", cursor: null, + ...CTX, }), ); expect(found.map((row) => row.id)).toEqual([old.id]); @@ -297,7 +310,13 @@ describe("the condition picker's window and search", () => { for (let guard = 0; guard < 5; guard++) { const page: Array<{ id: string }> = await get( listEpisodes as never, - episodePickerUrl({ direction: "newer", pivot, term: "", cursor }), + episodePickerUrl({ + direction: "newer", + pivot, + term: "", + cursor, + ...CTX, + }), ); for (const row of page) { expect(ids.has(row.id)).toBe(false); diff --git a/tests/integration/visit-link-picker-period-search.test.ts b/tests/integration/visit-link-picker-period-search.test.ts new file mode 100644 index 000000000..8c11213fa --- /dev/null +++ b/tests/integration/visit-link-picker-period-search.test.ts @@ -0,0 +1,399 @@ +/** + * A picker search for a year or a month returns exactly that period. + * + * Typing "2019" into a visit's link picker used to send `q=2019`, which the + * list routes match against names and titles, so filtering by a year found + * nothing. A term that names a period is now sent as the route's own date + * window. This drives every family's list route with the requests the + * pickers build (`picker-requests.ts`), against three years of records, and + * walks every page: the answer must be every record in the period and + * nothing else, including the records far beyond the first page. + * + * The expected sets are derived independently of the builders: an instant + * belongs to the period its day in the person's zone falls in + * (`userDayKey`), a document to the period its stored calendar date is in. + * The seeds put records just either side of each boundary in that zone. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { GET as listEncounters } from "@/app/api/encounters/route"; +import { GET as listDocuments } from "@/app/api/documents/inbound/route"; +import { GET as listEpisodes } from "@/app/api/illness/episodes/route"; +import { GET as listLabs } from "@/app/api/labs/route"; +import { + documentPickerUrl, + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "@/components/links/picker-requests"; +import { + PICKER_PAGE_SIZE, + nextOffset, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; +import { userDayKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "period-search-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +const TZ = "Europe/Berlin"; + +/** Every day from 2018-01-01 to 2020-12-31, as UTC-midnight instants. */ +const DAYS = Array.from( + { length: 1096 }, + (_, i) => new Date(Date.UTC(2018, 0, 1) + i * DAY_MS), +); + +/** + * Instants just either side of the period boundaries in Berlin: 23:30 and + * 00:30 local around New Year 2019 (UTC+1), the start of March 2019 (UTC+1) + * and the end of March 2019 (UTC+2, after the DST change), and New Year 2020. + */ +const EDGES = [ + "2018-12-31T22:30:00.000Z", + "2018-12-31T23:30:00.000Z", + "2019-02-28T22:30:00.000Z", + "2019-02-28T23:30:00.000Z", + "2019-03-31T21:30:00.000Z", + "2019-03-31T22:30:00.000Z", + "2019-12-31T22:30:00.000Z", + "2019-12-31T23:30:00.000Z", +].map((iso) => new Date(iso)); + +const search = (term: string, locale: string): PickerPageRequest => ({ + direction: "search", + pivot: null, + term, + cursor: null, + locale, + timeZone: TZ, +}); + +async function seedOwner() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "period-search", + email: "period-search@example.test", + timezone: TZ, + modulePreferencesJson: { + labs: true, + illness: true, + inboundDocuments: true, + }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * DAY_MS), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function get( + handler: (request: NextRequest) => Promise, + url: string, +): Promise { + const response = await handler(new NextRequest(`http://localhost${url}`)); + expect(response.status, url).toBe(200); + return ((await response.json()) as { data: T }).data; +} + +/** Ids of the instants whose day in the person's zone starts with `prefix`. */ +function expectedIds( + rows: ReadonlyArray<{ id: string; at: Date }>, + prefix: string, +): Set { + return new Set( + rows + .filter((row) => userDayKey(row.at, TZ).startsWith(prefix)) + .map((r) => r.id), + ); +} + +/** Walk an offset-paged search to its end, newest first, without repeats. */ +async function walkOffsets( + fetchPage: (cursor: string | null) => Promise, + at: (row: T) => string, +): Promise<{ ids: Set; pages: number }> { + const ids = new Set(); + let cursor: string | null = null; + let pages = 0; + let previous = Number.POSITIVE_INFINITY; + for (let guard = 0; guard < 30; guard++) { + const rows = await fetchPage(cursor); + pages += 1; + for (const row of rows) { + expect(ids.has(row.id), row.id).toBe(false); + ids.add(row.id); + const when = new Date(at(row)).getTime(); + expect(when).toBeLessThanOrEqual(previous); + previous = when; + } + cursor = nextOffset(cursor, rows.length); + if (cursor === null) break; + } + return { ids, pages }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedOwner(); +}); + +describe("lab results: a period search walks exactly the period", () => { + async function seed() { + const prisma = getPrismaClient(); + // A date-only reading is stored at noon UTC; the edges carry a time. + const instants = [ + ...DAYS.map((day) => new Date(day.getTime() + 12 * 60 * 60 * 1000)), + ...EDGES, + ]; + await prisma.labResult.createMany({ + data: instants.map((takenAt, i) => ({ + userId: OWNER_ID, + panel: "Metabolic", + analyte: `Glucose ${i}`, + value: 90, + unit: "mg/dL", + takenAt, + })), + }); + const rows = await prisma.labResult.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, takenAt: true }, + }); + return rows.map((row) => ({ id: row.id, at: row.takenAt })); + } + + const walk = (request: PickerPageRequest) => + walkOffsets( + async (cursor) => { + const page = await get<{ + results: Array<{ id: string; takenAt: string }>; + }>(listLabs as never, labPickerUrl({ ...request, cursor })); + return page.results; + }, + (row) => row.takenAt, + ); + + it("a year", async () => { + const rows = await seed(); + const expected = expectedIds(rows, "2019-"); + expect(expected.size).toBe(365 + 6); + const { ids, pages } = await walk(search("2019", "en")); + expect(pages).toBeGreaterThan(1); + expect(ids.size).toBeGreaterThan(PICKER_PAGE_SIZE); + expect(ids).toEqual(expected); + }); + + it.each([ + ["de", "März 2019"], + ["de", "03.2019"], + ["en", "Mar 2019"], + ["pl", "marzec 2019"], + ["ko", "2019년 3월"], + ])("a month (%s %j)", async (locale, term) => { + const rows = await seed(); + const expected = expectedIds(rows, "2019-03-"); + expect(expected.size).toBe(31 + 2); + const { ids } = await walk(search(term, locale)); + expect(ids).toEqual(expected); + }); + + it("a plain term is still a text search", async () => { + await seed(); + const { ids } = await walk(search("Glucose 7", "en")); + // "Glucose 7", "Glucose 70" … "Glucose 799", "Glucose 7xx": names only. + expect(ids.size).toBeGreaterThan(0); + expect(ids.size).toBeLessThan(200); + }); +}); + +describe("conditions: a period search walks exactly the period", () => { + it("a year and a month", async () => { + const prisma = getPrismaClient(); + const onsets = [ + ...DAYS.filter((_, i) => i % 2 === 0).map( + (day) => new Date(day.getTime() + 5 * 60 * 60 * 1000), + ), + ...EDGES, + ]; + await prisma.illnessEpisode.createMany({ + data: onsets.map((onsetAt, i) => ({ + userId: OWNER_ID, + label: `Cold ${i}`, + type: "INFECTION" as const, + onsetAt, + resolvedAt: new Date(onsetAt.getTime() + 3 * DAY_MS), + })), + }); + const rows = ( + await prisma.illnessEpisode.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, onsetAt: true }, + }) + ).map((row) => ({ id: row.id, at: row.onsetAt })); + + const walk = (request: PickerPageRequest) => + walkOffsets( + async (cursor) => { + const page = await get>( + listEpisodes as never, + episodePickerUrl({ ...request, cursor }), + ); + return page; + }, + (row) => row.onsetAt, + ); + + const year = await walk(search("2019", "fr")); + expect(year.ids.size).toBeGreaterThan(PICKER_PAGE_SIZE); + expect(year.ids).toEqual(expectedIds(rows, "2019-")); + + const month = await walk(search("mars 2019", "fr")); + expect(month.ids).toEqual(expectedIds(rows, "2019-03-")); + }); +}); + +describe("visits: the route without text search still searches a period", () => { + it("a year and a month", async () => { + const prisma = getPrismaClient(); + const instants = [ + ...DAYS.map((day) => new Date(day.getTime() + 9 * 60 * 60 * 1000)), + ...EDGES, + ]; + await prisma.encounter.createMany({ + data: instants.map((occurredAt) => ({ + userId: OWNER_ID, + occurredAt, + status: "DONE" as const, + kind: "ROUTINE" as const, + })), + }); + const rows = ( + await prisma.encounter.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, occurredAt: true }, + }) + ).map((row) => ({ id: row.id, at: row.occurredAt })); + + const walk = (request: PickerPageRequest) => + walkOffsets( + async (cursor) => { + const list = await get<{ + upcoming: Array<{ id: string; occurredAt: string }>; + past: Array<{ id: string; occurredAt: string }>; + }>( + listEncounters as never, + encounterPickerUrl({ ...request, cursor }), + ); + expect(list.upcoming).toEqual([]); + return list.past; + }, + (row) => row.occurredAt, + ); + + const year = await walk(search("2019", "it")); + expect(year.pages).toBeGreaterThan(1); + expect(year.ids).toEqual(expectedIds(rows, "2019-")); + + const month = await walk(search("marzo 2019", "it")); + expect(month.ids).toEqual(expectedIds(rows, "2019-03-")); + }); +}); + +describe("documents: the day-granular window is the period's calendar days", () => { + it("a year and a month, across keyset pages", async () => { + const prisma = getPrismaClient(); + await prisma.inboundDocument.createMany({ + data: DAYS.map((day, i) => ({ + userId: OWNER_ID, + kind: "OTHER" as const, + filename: `doc-${i}.pdf`, + mimeType: "application/pdf", + byteSize: 4, + status: "STORED" as const, + contentEncrypted: Buffer.from("test"), + contentCodec: "v1", + documentDate: day, + })), + }); + const rows = await prisma.inboundDocument.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, documentDate: true }, + }); + const expected = (prefix: string) => + new Set( + rows + .filter((row) => row.documentDate!.toISOString().startsWith(prefix)) + .map((row) => row.id), + ); + + async function walk(request: PickerPageRequest) { + const ids = new Set(); + let cursor: string | null = null; + let pages = 0; + for (let guard = 0; guard < 30; guard++) { + const page: { + documents: Array<{ id: string }>; + nextCursor: string | null; + } = await get( + listDocuments as never, + documentPickerUrl({ ...request, cursor }), + ); + pages += 1; + for (const doc of page.documents) { + expect(ids.has(doc.id)).toBe(false); + ids.add(doc.id); + } + cursor = page.nextCursor; + if (cursor === null) break; + } + return { ids, pages }; + } + + const year = await walk(search("2019", "es")); + expect(year.pages).toBeGreaterThan(1); + expect(year.ids).toEqual(expected("2019-")); + expect(year.ids.size).toBe(365); + + const month = await walk(search("marzo de 2019", "es")); + expect(month.ids).toEqual(expected("2019-03-")); + expect(month.ids.size).toBe(31); + }); +});