mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(links): prove a period search returns exactly that period
Parser cases for every accepted form in de, en, es, fr, it, pl and ko and the near misses that stay text; builder cases for every family; and an integration walk over three years of lab results, conditions, visits and documents, with records either side of each boundary in the person's zone, across every page.
This commit is contained in:
1 parent
e16c9fb717
commit
20a1ac4f7d
7 files changed
+743
-13
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({
|
||||
term: "03.2019",
|
||||
filterLocally: true,
|
||||
period: {
|
||||
year: 2019,
|
||||
month: 3,
|
||||
fromDay: "2019-03-01",
|
||||
toDay: "2019-03-31",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({ term: "metabolic" })}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> {
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Array<{ id: string; onsetAt: string }>>(
|
||||
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);
|
||||
|
||||
@@ -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<T>(
|
||||
handler: (request: NextRequest) => Promise<Response>,
|
||||
url: string,
|
||||
): Promise<T> {
|
||||
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<string> {
|
||||
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<T extends { id: string }>(
|
||||
fetchPage: (cursor: string | null) => Promise<T[]>,
|
||||
at: (row: T) => string,
|
||||
): Promise<{ ids: Set<string>; pages: number }> {
|
||||
const ids = new Set<string>();
|
||||
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<Array<{ id: string; onsetAt: string }>>(
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user