mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): give the vault specs their own accounts, and clear every bucket at setup
The vault's layout is account state. documents.spec read the default while the layout journey in v142-qol turned it on the shared account, so which one failed depended on the order they ran in. Each now runs on a vault account of its own; the vault fixture seeds the same corpus under ids tagged per account, so two vaults never claim one row. Global setup clears every rate-limit bucket keyed on a fixture account or on the loopback addresses the suite calls from, not only the login and share buckets: the check-in, write, export and import buckets outlive a run by up to an hour, and a second run on the same database met the first run's counts. A developer's own accounts are left alone. The layout journey waits for its own write rather than whichever layout write answers first.
This commit is contained in:
1 parent
3b6e0bf0fb
commit
5273a8d899
4 files changed
+180
-36
No files matched your search
+12
-8
@@ -20,15 +20,19 @@ import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { E2E_VAULT, VAULT_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import {
|
||||
ensureVaultFixture,
|
||||
seedNamespaceDocs,
|
||||
KNIE_EPISODE_ID,
|
||||
MRT_DOC_ID,
|
||||
vaultFixtureIds,
|
||||
} from "./setup/vault-fixture";
|
||||
|
||||
// The vault on an account of its own (`E2E_VAULT`): its layout is account
|
||||
// state, and the month-grid tests read the default.
|
||||
const { knieEpisodeId: KNIE_EPISODE_ID, mrtDocId: MRT_DOC_ID } =
|
||||
vaultFixtureIds(E2E_VAULT.username);
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 26_214_400;
|
||||
|
||||
/** Scroll the shell's scroll container until `locator` is visible (the
|
||||
@@ -145,10 +149,10 @@ test.describe("document vault", () => {
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
});
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
test.use({ storageState: VAULT_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureVaultFixture();
|
||||
await ensureVaultFixture(E2E_VAULT.username);
|
||||
});
|
||||
|
||||
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
|
||||
@@ -532,7 +536,7 @@ test.describe("document vault", () => {
|
||||
test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => {
|
||||
test.skip(isMobile, "range selection is a desktop pointer flow");
|
||||
test.setTimeout(90_000);
|
||||
await seedNamespaceDocs("bulkfifty", 50);
|
||||
await seedNamespaceDocs("bulkfifty", 50, E2E_VAULT.username);
|
||||
|
||||
// Shared filing date → the namespace orders by id desc: "050" first,
|
||||
// "001" last. Anchor on the first card, shift-click the last.
|
||||
@@ -568,7 +572,7 @@ test.describe("document vault", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "bulk selection is a desktop pointer flow");
|
||||
await seedNamespaceDocs("bulkpf", 5);
|
||||
await seedNamespaceDocs("bulkpf", 5, E2E_VAULT.username);
|
||||
|
||||
await page.goto("/documents?q=bulkpf");
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
@@ -603,7 +607,7 @@ test.describe("document vault", () => {
|
||||
}) => {
|
||||
test.skip(isMobile, "range selection is a desktop pointer flow");
|
||||
test.setTimeout(120_000);
|
||||
await seedNamespaceDocs("bulkchunk", 101);
|
||||
await seedNamespaceDocs("bulkchunk", 101, E2E_VAULT.username);
|
||||
|
||||
// Newest-first by id tiebreak: "101" renders first, "001" last. Walk
|
||||
// the infinite query to the end (pages of 50) so the range can span
|
||||
|
||||
@@ -251,6 +251,41 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateTimeline.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The vault specs' own accounts (`documents.spec.ts`, and the documents
|
||||
* journeys of `v142-qol.spec.ts`).
|
||||
*
|
||||
* The vault's layout (cards or a list, months stacked or side by side) is a
|
||||
* per-account preference. On the shared account the layout journey turned it
|
||||
* while the month-grid tests read the default, so either could fail depending
|
||||
* on which ran first. Each file seeds its own vault into its own account
|
||||
* (`ensureVaultFixture(username)`).
|
||||
*/
|
||||
export const E2E_VAULT = {
|
||||
email: "e2e-vault@healthlog.test",
|
||||
username: "e2e-vault",
|
||||
password: "Vt4!Hq8mZr2wKx6N",
|
||||
// The upload-cap test sets the instance's cap through the admin console.
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
export const VAULT_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateVault.json",
|
||||
);
|
||||
|
||||
export const E2E_VAULT_LAYOUT = {
|
||||
email: "e2e-vault-layout@healthlog.test",
|
||||
username: "e2e-vault-layout",
|
||||
password: "Vl7!Bn3qXw9pRt5M",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateVaultLayout.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The day-doors journey's own account (`day-doors.spec.ts`).
|
||||
*
|
||||
@@ -991,6 +1026,32 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns
|
||||
// the documents module on and seeds the documents.
|
||||
for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) {
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, $6, $5, $5, $5, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||||
[
|
||||
cuid(),
|
||||
account.username,
|
||||
account.email,
|
||||
await hashPassword(account.password),
|
||||
now,
|
||||
account.role,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// The day-doors journey's account. Every module the journey reaches is
|
||||
// on; its record is written after the login capture.
|
||||
await pool.query(
|
||||
@@ -1365,6 +1426,28 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// surfaces' own buckets are cleared; nothing else in the table is touched.
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||||
|
||||
// Every other bucket the suite can fill, for the same reason. A bucket is
|
||||
// keyed on the account it meters (`<surface>:<id>`, sometimes with a
|
||||
// further segment) or, on an anonymous surface, on the caller's address,
|
||||
// and several of them outlive a run by up to an hour: the check-in
|
||||
// ceiling, the per-account write bucket, the export and import buckets.
|
||||
// A second run on the same database then met the first run's counts.
|
||||
// Cleared are the buckets of the fixture accounts (every one of them is
|
||||
// an `@healthlog.test` address or an `e2e-` record) and those of the
|
||||
// loopback addresses the suite calls from; a developer's own accounts
|
||||
// and their buckets are left alone.
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits r
|
||||
USING users u
|
||||
WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%')
|
||||
AND position(u.id IN r.key) > 0`,
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1'
|
||||
OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`,
|
||||
);
|
||||
|
||||
// One marker weight per record. Re-seeded by delete-then-insert so a
|
||||
// re-run cannot accumulate duplicates and so an edited constant takes
|
||||
// effect rather than sitting beside the old value.
|
||||
@@ -1690,6 +1773,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// The timeline journey's jar — see `E2E_TIMELINE`.
|
||||
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
|
||||
|
||||
// The vault specs' jars — see `E2E_VAULT`.
|
||||
await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH);
|
||||
|
||||
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
|
||||
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
|
||||
+60
-16
@@ -188,6 +188,39 @@ function jpegWithGps(marker: string): Buffer {
|
||||
|
||||
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
|
||||
export const MRT_DOC_ID = "e2evaultmrt0000000000001";
|
||||
|
||||
/**
|
||||
* The vault corpus's ids for one account. The shared account keeps the ids
|
||||
* above; an account of its own gets the same corpus under ids of the same
|
||||
* length with its own five-letter tag in place of `vault`, so two accounts'
|
||||
* vaults never claim one row. Only `ensureVaultFixture` and
|
||||
* `seedNamespaceDocs` take an account.
|
||||
*/
|
||||
const VAULT_TAGS: Record<string, string> = {
|
||||
"e2e-vault": "vspec",
|
||||
"e2e-vault-layout": "vqolx",
|
||||
};
|
||||
|
||||
function vaultTag(username: string): string {
|
||||
if (username === E2E_USER.username) return "vault";
|
||||
const tag = VAULT_TAGS[username];
|
||||
if (!tag) throw new Error(`[vault-fixture] no id tag for ${username}`);
|
||||
return tag;
|
||||
}
|
||||
|
||||
function inVault(id: string, username: string): string {
|
||||
return id.replace(/^e2evault/, `e2e${vaultTag(username)}`);
|
||||
}
|
||||
|
||||
export function vaultFixtureIds(username: string): {
|
||||
knieEpisodeId: string;
|
||||
mrtDocId: string;
|
||||
} {
|
||||
return {
|
||||
knieEpisodeId: inVault(KNIE_EPISODE_ID, username),
|
||||
mrtDocId: inVault(MRT_DOC_ID, username),
|
||||
};
|
||||
}
|
||||
/** A stored PDF the AI-assist specs drive (mocked provider); title/kind are
|
||||
* bland so an applied suggestion is visibly different. */
|
||||
export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
|
||||
@@ -268,10 +301,10 @@ async function withVaultSeedLock(
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserId(pool: pg.Pool): Promise<string> {
|
||||
async function getUserId(pool: pg.Pool, username: string): Promise<string> {
|
||||
const res = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[E2E_USER.username],
|
||||
[username],
|
||||
);
|
||||
const id = res.rows[0]?.id;
|
||||
if (!id) {
|
||||
@@ -344,7 +377,7 @@ export async function resetEncounters(): Promise<void> {
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
await pool.query(
|
||||
"DELETE FROM encounter_document_links WHERE user_id = $1",
|
||||
[userId],
|
||||
@@ -360,13 +393,15 @@ export async function resetEncounters(): Promise<void> {
|
||||
* corpus. Safe to call from every spec's beforeAll — every statement
|
||||
* upserts.
|
||||
*/
|
||||
export async function ensureVaultFixture(): Promise<void> {
|
||||
export async function ensureVaultFixture(
|
||||
username: string = E2E_USER.username,
|
||||
): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
await withVaultSeedLock(pool, async () => {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, username);
|
||||
|
||||
// Opt-in module: merge, never overwrite other module preferences.
|
||||
await pool.query(
|
||||
@@ -385,7 +420,7 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`,
|
||||
[KNIE_EPISODE_ID, userId],
|
||||
[inVault(KNIE_EPISODE_ID, username), userId],
|
||||
);
|
||||
|
||||
// 60 background documents spread over 36 months (newest ~current).
|
||||
@@ -393,7 +428,10 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const monthsBack = Math.floor((i * 36) / 60);
|
||||
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
|
||||
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
|
||||
const id = inVault(
|
||||
`e2evaultdoc${String(i).padStart(12, "0")}`,
|
||||
username,
|
||||
);
|
||||
docs.push({
|
||||
id,
|
||||
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
|
||||
@@ -406,17 +444,17 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
}
|
||||
// The MRT report + two adjacent images, same date, all linked to Knie.
|
||||
docs.push({
|
||||
id: MRT_DOC_ID,
|
||||
id: inVault(MRT_DOC_ID, username),
|
||||
kind: "IMAGING",
|
||||
title: "MRT Knie",
|
||||
filename: "mrt-knie.pdf",
|
||||
mimeType: "application/pdf",
|
||||
documentDate: MRT_DOC_DATE,
|
||||
bytes: tinyPdf(MRT_DOC_ID),
|
||||
bytes: tinyPdf(inVault(MRT_DOC_ID, username)),
|
||||
});
|
||||
for (const n of [1, 2]) {
|
||||
docs.push({
|
||||
id: `e2evaultimg000000000000${n}`,
|
||||
id: inVault(`e2evaultimg000000000000${n}`, username),
|
||||
kind: "IMAGING",
|
||||
title: `MRT Aufnahme ${n}`,
|
||||
filename: `mrt-aufnahme-${n}.png`,
|
||||
@@ -432,14 +470,19 @@ export async function ensureVaultFixture(): Promise<void> {
|
||||
MRT_DOC_ID,
|
||||
"e2evaultimg0000000000001",
|
||||
"e2evaultimg0000000000002",
|
||||
];
|
||||
].map((id) => inVault(id, username));
|
||||
for (const [i, docId] of linked.entries()) {
|
||||
await pool.query(
|
||||
`INSERT INTO document_condition_links
|
||||
(id, document_id, episode_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (document_id, episode_id) DO NOTHING`,
|
||||
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
|
||||
[
|
||||
inVault(`e2evaultlink00000000000${i + 1}`, username),
|
||||
docId,
|
||||
inVault(KNIE_EPISODE_ID, username),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -462,7 +505,7 @@ export async function ensureVaultAiFixture(): Promise<void> {
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
await withVaultSeedLock(pool, async () => {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
|
||||
await upsertDocs(pool, userId, [
|
||||
{
|
||||
@@ -522,12 +565,13 @@ export async function ensureVaultAiFixture(): Promise<void> {
|
||||
export async function seedNamespaceDocs(
|
||||
prefix: string,
|
||||
count: number,
|
||||
username: string = E2E_USER.username,
|
||||
): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, username);
|
||||
const docs: DocRow[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `e2e${prefix}${String(i).padStart(8, "0")}`;
|
||||
@@ -569,7 +613,7 @@ export async function ensureProcedureFixture(): Promise<void> {
|
||||
await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3);
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
const procedures = [
|
||||
{
|
||||
id: PROCEDURE_KNEE_ID,
|
||||
@@ -636,7 +680,7 @@ export async function ensureShareDocFixture(): Promise<void> {
|
||||
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const userId = await getUserId(pool);
|
||||
const userId = await getUserId(pool, E2E_USER.username);
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET module_preferences_json =
|
||||
|
||||
+21
-12
@@ -16,7 +16,11 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
E2E_VAULT_LAYOUT,
|
||||
STORAGE_STATE_PATH,
|
||||
VAULT_LAYOUT_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
|
||||
@@ -36,17 +40,18 @@ async function putDocumentsLayout(
|
||||
const resetDocumentsLayout = (page: Page) =>
|
||||
putDocumentsLayout(page, "stacked");
|
||||
|
||||
/**
|
||||
* The account is shared with `documents.spec.ts`, which reads the default
|
||||
* (flowing) arrangement, so the journey hands the account back on the
|
||||
* default rather than on the state it started from.
|
||||
*/
|
||||
/** And leaves its account on the default, as a new account would read it. */
|
||||
const restoreDefaultDocumentsLayout = (page: Page) =>
|
||||
putDocumentsLayout(page, "flow");
|
||||
|
||||
// The vault's layout is account state, and this journey turns it, so these
|
||||
// tests run on a vault account of their own (`E2E_VAULT_LAYOUT`); the month
|
||||
// grid tests in `documents.spec.ts` read the default on theirs.
|
||||
test.describe("documents", () => {
|
||||
test.use({ storageState: VAULT_LAYOUT_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureVaultFixture();
|
||||
await ensureVaultFixture(E2E_VAULT_LAYOUT.username);
|
||||
});
|
||||
|
||||
test("the display choice behind the wrench persists across visits", async ({
|
||||
@@ -71,11 +76,15 @@ test.describe("documents", () => {
|
||||
settings.locator('[data-slot="module-view-list"]'),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
|
||||
// Wait for the write to land, so the reload below reads it back.
|
||||
// Wait for THIS write to land, so the reload below reads it back. The
|
||||
// view switch above writes too, and its answer can arrive after this
|
||||
// wait starts; matching on the arrangement keeps the two apart.
|
||||
const saved = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/documents/inbound/layout") &&
|
||||
r.request().method() === "PUT",
|
||||
r.request().method() === "PUT" &&
|
||||
(r.request().postDataJSON() as { arrangement?: string } | null)
|
||||
?.arrangement === "flow",
|
||||
);
|
||||
await flow.click();
|
||||
expect((await saved).status()).toBe(200);
|
||||
@@ -111,7 +120,7 @@ test.describe("documents", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "selection by checkbox is a pointer flow");
|
||||
await seedNamespaceDocs("qoldel", 2);
|
||||
await seedNamespaceDocs("qoldel", 2, E2E_VAULT_LAYOUT.username);
|
||||
await page.goto("/documents?q=qoldel");
|
||||
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
|
||||
await expect(box).toBeVisible();
|
||||
@@ -155,7 +164,7 @@ test.describe("documents", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "keyboard flow");
|
||||
await seedNamespaceDocs("qolkey", 1);
|
||||
await seedNamespaceDocs("qolkey", 1, E2E_VAULT_LAYOUT.username);
|
||||
await page.goto("/documents?q=qolkey");
|
||||
const open = page.getByRole("button", { name: "Open qolkey 001" });
|
||||
await expect(open).toBeVisible();
|
||||
@@ -173,7 +182,7 @@ test.describe("documents", () => {
|
||||
isMobile,
|
||||
}) => {
|
||||
test.skip(isMobile, "selection by checkbox is a pointer flow");
|
||||
await seedNamespaceDocs("qolclear", 2);
|
||||
await seedNamespaceDocs("qolclear", 2, E2E_VAULT_LAYOUT.username);
|
||||
await page.goto("/documents?q=qolclear");
|
||||
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
|
||||
|
||||
|
||||
Reference in new issue
Block a user