mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release.
This commit is contained in:
1 parent
d98e85cd03
commit
4f09c3f862
314 files changed
+26801
-4419
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/admin/app-logs` summary line lifted out of the
|
||||
* `overflow-x-auto` table wrapper so admins on narrow viewports can
|
||||
* still see "showing X of Y events" without first scrolling the table
|
||||
* back to its starting offset. The refresh control already lives in
|
||||
* the section header.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/app-logs",
|
||||
}));
|
||||
|
||||
const sampleEvents = [
|
||||
{
|
||||
request_id: "req-1",
|
||||
trace_id: "abcdef1234567890",
|
||||
level: "info" as const,
|
||||
timestamp: "2026-05-10T09:00:00Z",
|
||||
duration_ms: 12,
|
||||
action: { name: "auth.login.success" },
|
||||
kind: "request",
|
||||
http: { method: "POST", path: "/api/auth/login" },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
events: sampleEvents,
|
||||
meta: { total: 1, bufferMax: 500 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AppLogPreviewSection } from "../app-log-preview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AppLogPreviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () => {
|
||||
it("renders the summary row with the stable test id", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="app-log-preview-summary"');
|
||||
});
|
||||
|
||||
it("places the summary row outside any `overflow-x-auto` container", () => {
|
||||
const html = render();
|
||||
const summaryIdx = html.indexOf('data-testid="app-log-preview-summary"');
|
||||
expect(summaryIdx).toBeGreaterThan(-1);
|
||||
const before = html.slice(0, summaryIdx);
|
||||
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
|
||||
expect(lastScrollOpen).toBeGreaterThan(-1);
|
||||
const tail = before.slice(lastScrollOpen);
|
||||
expect(tail).toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not nest the summary copy inside the scroll wrapper", () => {
|
||||
const html = render();
|
||||
const wrapper = html.match(
|
||||
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper![0]).not.toContain("app-log-preview-summary");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
carrierShortLabel,
|
||||
type AuditCsvEntry,
|
||||
type AuditCsvLabels,
|
||||
} from "../_shared";
|
||||
@@ -30,6 +31,9 @@ const LABELS_EN: AuditCsvLabels = {
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
// v1.4.27 B3 — `admin.carrier` is shipped by bucket B6; the test
|
||||
// pins the English literal so the column order is locked.
|
||||
carrier: "Carrier",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
@@ -52,6 +56,7 @@ const LABELS_DE: AuditCsvLabels = {
|
||||
user: "Benutzer",
|
||||
ip: "IP",
|
||||
location: "Standort",
|
||||
carrier: "Mobilfunkanbieter",
|
||||
provider: "Anbieter",
|
||||
outcome: "Ergebnis",
|
||||
action: "Aktion",
|
||||
@@ -98,6 +103,7 @@ describe("buildAuditLogCsvRecords", () => {
|
||||
"user",
|
||||
"ip",
|
||||
"location",
|
||||
"carrier",
|
||||
"provider",
|
||||
"outcome",
|
||||
"action",
|
||||
@@ -196,6 +202,7 @@ describe("auditLogCsvHeaderLabels", () => {
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
carrier: "Carrier",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
@@ -224,13 +231,105 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => {
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
|
||||
const lines = csv.split("\n");
|
||||
expect(lines[0]).toBe(
|
||||
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
|
||||
"Zeitpunkt,Benutzer,IP,Standort,Mobilfunkanbieter,Anbieter,Ergebnis,Aktion,Details",
|
||||
);
|
||||
expect(lines[1]).toBe(
|
||||
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,',
|
||||
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",,Passkey,Erfolgreich,auth.login.passkey,',
|
||||
);
|
||||
});
|
||||
|
||||
it("folds the GeoLite2 organisation string to the short DACH carrier label", () => {
|
||||
// Marc's spec: the verbose MMDB org strings get a glanceable chip.
|
||||
// The CSV column carries the same folded text so a downstream
|
||||
// spreadsheet matches what the admin overview screen renders.
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "84.131.0.1",
|
||||
location: "Berlin, DE",
|
||||
carrier: "Deutsche Telekom AG",
|
||||
asn: 3320,
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:06:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "139.7.0.1",
|
||||
location: "Hamburg, DE",
|
||||
carrier: "Vodafone GmbH",
|
||||
asn: 3209,
|
||||
details: null,
|
||||
user: { id: "u2", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:07:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "212.7.0.1",
|
||||
location: "Köln, DE",
|
||||
carrier: "1&1 Versatel GmbH",
|
||||
asn: 8881,
|
||||
details: null,
|
||||
user: { id: "u3", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:08:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "92.7.0.1",
|
||||
location: "Frankfurt, DE",
|
||||
carrier: "Telefónica Germany GmbH & Co. OHG",
|
||||
asn: 6805,
|
||||
details: null,
|
||||
user: { id: "u4", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:09:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "8.8.8.8",
|
||||
location: "Mountain View, US",
|
||||
carrier: "Google LLC",
|
||||
asn: 15169,
|
||||
details: null,
|
||||
user: { id: "u5", username: "marc" },
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(records.map((r) => r.carrier)).toEqual([
|
||||
"Telekom",
|
||||
"Vodafone",
|
||||
"1&1",
|
||||
"O2",
|
||||
// Unknown organisations carry the raw GeoLite2 string through.
|
||||
"Google LLC",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits an empty carrier cell when the row has no ASN data", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "192.0.2.1",
|
||||
location: null,
|
||||
carrier: null,
|
||||
asn: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const [record] = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(record.carrier).toBe("");
|
||||
});
|
||||
|
||||
it("escapes commas, quotes, and newlines in the details column", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
@@ -256,3 +355,35 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
it("folds 'Deutsche Telekom AG' down to 'Telekom'", () => {
|
||||
expect(carrierShortLabel("Deutsche Telekom AG")).toBe("Telekom");
|
||||
});
|
||||
|
||||
it("folds the standalone Vodafone variants down to 'Vodafone'", () => {
|
||||
expect(carrierShortLabel("Vodafone GmbH")).toBe("Vodafone");
|
||||
expect(carrierShortLabel("Vodafone Kabel Deutschland GmbH")).toBe(
|
||||
"Vodafone",
|
||||
);
|
||||
});
|
||||
|
||||
it("folds the 1&1 sub-brands down to '1&1'", () => {
|
||||
expect(carrierShortLabel("1&1 Telecom GmbH")).toBe("1&1");
|
||||
expect(carrierShortLabel("1&1 Versatel GmbH")).toBe("1&1");
|
||||
});
|
||||
|
||||
it("folds Telefónica + O2 variants down to 'O2'", () => {
|
||||
expect(
|
||||
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
|
||||
).toBe("O2");
|
||||
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
|
||||
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
|
||||
});
|
||||
|
||||
it("returns the raw organisation string for unknown carriers", () => {
|
||||
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
|
||||
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/admin/login-overview` pagination + summary lifted
|
||||
* out of the `overflow-x-auto` table wrapper.
|
||||
*
|
||||
* The audit table can be wider than the viewport on phones, so it
|
||||
* stays in an `overflow-x-auto` container that pans horizontally on
|
||||
* touch. Before this fix the prev/next controls lived *inside* that
|
||||
* same wrapper, meaning admins on narrow viewports had to scroll the
|
||||
* table back to its starting offset before they could reach the
|
||||
* pagination controls. The summary "showing X of Y" line was hidden
|
||||
* the same way. The fix moves both into a sibling block under the
|
||||
* scroll container.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/login-overview",
|
||||
}));
|
||||
|
||||
const sampleEntries = [
|
||||
{
|
||||
id: "ev-1",
|
||||
user: { id: "u1", username: "marc" },
|
||||
action: "auth.login.success",
|
||||
ipAddress: "203.0.113.10",
|
||||
location: "Berlin, DE",
|
||||
carrier: null,
|
||||
createdAt: "2026-05-10T09:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
entries: sampleEntries,
|
||||
meta: { total: 23, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LoginOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginOverviewSection — pagination lives outside the scroll wrapper", () => {
|
||||
it("renders the pagination row with the stable test id", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="login-overview-pagination"');
|
||||
});
|
||||
|
||||
it("places the pagination row outside any `overflow-x-auto` container", () => {
|
||||
const html = render();
|
||||
// Capture the pagination block and assert that the rendered
|
||||
// markup leading up to its opening tag closes the scroll
|
||||
// container first. The simplest invariant: there is no
|
||||
// `<div class="overflow-x-auto">` ancestor that is still open
|
||||
// when the pagination div opens. We approximate this by finding
|
||||
// the last `<div class="overflow-x-auto"` before the pagination
|
||||
// div and confirming the inner content closes (matching `</div>`)
|
||||
// before the pagination div opens.
|
||||
const paginationIdx = html.indexOf(
|
||||
'data-testid="login-overview-pagination"',
|
||||
);
|
||||
expect(paginationIdx).toBeGreaterThan(-1);
|
||||
const before = html.slice(0, paginationIdx);
|
||||
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
|
||||
expect(lastScrollOpen).toBeGreaterThan(-1);
|
||||
// Between the scroll-wrapper opening and the pagination opening
|
||||
// there must be at least one `</div>` closing the wrapper.
|
||||
const tail = before.slice(lastScrollOpen);
|
||||
expect(tail).toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not nest the prev/next buttons inside the scroll wrapper", () => {
|
||||
const html = render();
|
||||
// Pin the entire scroll wrapper and confirm the prev/next button
|
||||
// labels are not within it. We slice the markup between the
|
||||
// wrapper's opening tag and its first balanced closing tag at the
|
||||
// same depth — naively by grabbing up to `</table></div>`, which
|
||||
// matches the structure produced by the current implementation.
|
||||
const wrapper = html.match(
|
||||
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper![0]).not.toContain("admin.section.auditLog.next");
|
||||
// The English labels also must not appear inside the wrapper.
|
||||
expect(wrapper![0]).not.toMatch(/>\s*Next\s*</);
|
||||
expect(wrapper![0]).not.toMatch(/>\s*Previous\s*</);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user