fix(e12): close what round one broke, and revert two fixes that overreached
Round two of the review loop, on round one's own diff. Two of its findings were regressions the fixes themselves introduced, which is exactly what the loop is for: **A per-user refusal was writing onto a shared row.** MediaAssets are deduped across users, and the generic failure handler puts the message on the asset — right for an upstream failure, wrong for "your storage is full". Two users queuing the same video share one asset, so the first user hitting their cap failed the second user's job with the first one's private reason, telling someone with an empty library to free up space. The refusal now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to `pending` for whoever else holds it. Pinned by a test that drives `_download` directly — the refusal happens before extraction, so nothing needs stubbing. **The restore scripts restarted the app, undoing the restore.** `docker compose start` re-runs the container command, and that runs `alembic upgrade head` — so on the rollback path this documents as recommended (restore the dump, THEN pin the older tag) the newer image immediately re-applied the migrations the restore had just undone, and on a FAILED restore it put the app back on a half-restored schema. Both are the opposite of why the stop was added. Nothing is restarted automatically now; the scripts say what to run and when, and the docs' snippets say the same. **Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders that failure inline, next to a Retry button — three recoveries for one failure), and `useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out card's only banner, where nothing renders the reset flag to replace it. The two states are independent again; what changes is that `dismiss` peels ONE layer, which is what the original finding was actually about. Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403 detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed` since "bad port" misdescribes it; three docstrings narrowed to what the code actually does (the worker's byte check is per-job, not an aggregate ledger, and not on the edit path; the redirect residual leaks a reachability oracle, which the note had denied; the invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw` can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were missing for the round-one fixes — the account panel's error paths, the invalidation set, and the shared-asset case above. Gate green: vitest 685, pytest 950.
This commit is contained in:
@@ -387,7 +387,6 @@ function PendingAccess() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the resend fail because this instance has no working SMTP? */
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
|
||||
@@ -5,7 +5,6 @@ import { qk } from "../lib/queryKeys";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import Overlay from "./Overlay";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
@@ -79,10 +78,6 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
},
|
||||
// Say something rather than nothing: this route is rate-limited (SEC-14) and can 403 without a
|
||||
// read grant, and a swallowed rejection here leaves the wizard looking like it simply did not
|
||||
// run. The server's own reason wins over the fallback.
|
||||
onError: (e) => notifyActionError(e, t("onboarding.importFailed")),
|
||||
});
|
||||
|
||||
// First time we see read access with zero imported channels, pull the user's
|
||||
|
||||
@@ -6,7 +6,7 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -65,10 +65,11 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
// Via notifyActionError so the server's own reason wins over the generic fallback: this route
|
||||
// is rate-limited (SEC-14), and "Sync failed" would report a pace limit as a fault while the
|
||||
// same button on the Channels page explains it. A zero-arg onError discards that detail.
|
||||
onError: (e) => notifyActionError(e, t("stats.sync.syncFailed")),
|
||||
// The same helper the Channels page uses, for the same two reasons: it translates the 403
|
||||
// ("connect YouTube") instead of surfacing the route's bare English detail under a Hungarian
|
||||
// UI, and it delegates everything else to notifyActionError so the server's own reason wins —
|
||||
// which is what makes the SEC-14 cooldown read as a pace limit rather than "Sync failed".
|
||||
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed")),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
|
||||
@@ -18,10 +18,11 @@ export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
|
||||
// silence is precisely what made the verify half a bug. The token stays in the address bar, so
|
||||
// signing out from this same tab picks it up.
|
||||
//
|
||||
// Tested on `resetPending` ALONE, not `!outcome && resetPending`: the hook keeps the two states
|
||||
// mutually exclusive (each new link clears the other), so an extra `!outcome` here would only add
|
||||
// an ordering dependency on the early return below — tidy that return upwards one day and the
|
||||
// banner silently becomes unreachable, which is the bug this branch was added to fix.
|
||||
// Tested on `resetPending` ALONE, not `!outcome && resetPending`. The two can both be set (a
|
||||
// reset link pasted over an unread confirmation, or a slow verify resolving after one), and this
|
||||
// one goes first because it is the banner with something to DO. `dismiss` then peels it and the
|
||||
// outcome shows underneath — where `!outcome &&` would have hidden this one until the other was
|
||||
// dismissed, and dismissing that used to clear both.
|
||||
if (entry.resetPending) {
|
||||
return (
|
||||
<Banner
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin account panel's RECOVERY actions, and specifically that a refusal is visible.
|
||||
//
|
||||
// All three call `quiet: true` endpoints, which suppress the global error dialog on the promise that
|
||||
// the caller reports the failure inline. Without an `onError` that promise is broken and a refusal
|
||||
// renders as nothing at all — and on the broken-SMTP instance this panel exists for, an admin reads
|
||||
// silence as "the mail went out". These pin that each failure says something, and that the resend
|
||||
// distinguishes "this instance has no working mail" from a genuine send failure.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const access = {
|
||||
email_verified: false,
|
||||
email_configured: true,
|
||||
is_active: false,
|
||||
is_allowed: true,
|
||||
can_activate: true,
|
||||
has_password: false,
|
||||
google_email: null,
|
||||
invite_status: null,
|
||||
};
|
||||
|
||||
const apiMock = {
|
||||
adminUserAccess: vi.fn(async () => access),
|
||||
createSetPasswordLink: vi.fn(),
|
||||
resendUserVerification: vi.fn(),
|
||||
activateUser: vi.fn(),
|
||||
};
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
const { HttpError } = await import("../lib/api");
|
||||
const AccountPanel = (await import("./AccountPanel")).default;
|
||||
|
||||
const user = {
|
||||
id: 7,
|
||||
email: "stuck@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
};
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<AccountPanel user={user as any} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Click the recovery button whose label is `key`, once the readout has loaded. */
|
||||
async function clickAction(key: string) {
|
||||
mount();
|
||||
await screen.findByText("users.account.recovery");
|
||||
fireEvent.click(screen.getByText(key));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
Object.values(apiMock).forEach((m) => m.mockClear());
|
||||
apiMock.adminUserAccess.mockResolvedValue(access);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const errorMessages = () =>
|
||||
notifyMock.mock.calls
|
||||
.map(([a]) => a as { level?: string; message?: string })
|
||||
.filter((a) => a.level === "error")
|
||||
.map((a) => a.message);
|
||||
|
||||
describe("the account panel reports a refused recovery action", () => {
|
||||
it("says the link could not be minted instead of leaving the button inert", async () => {
|
||||
apiMock.createSetPasswordLink.mockRejectedValue(new HttpError(404, "gone", null));
|
||||
|
||||
await clickAction("users.account.setPassword");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed"));
|
||||
// …and no link block was rendered, so the toast is the only thing that happened.
|
||||
expect(screen.queryByText("users.account.copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(422, "send failed", { code: "resend", reason: "send_failed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed"));
|
||||
// The success toast must NOT also fire — that is the exact lie the backend guards against.
|
||||
expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain(
|
||||
"users.account.resendSent",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a refused activation", async () => {
|
||||
apiMock.activateUser.mockRejectedValue(
|
||||
new HttpError(409, "not allowed", { code: "activate", reason: "not_allowed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.activate");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorMessages()).toContain("users.pendingAccess.activateFailed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -235,7 +235,7 @@
|
||||
"unresolvable": "That address couldn't be looked up. Check the link and try again.",
|
||||
"private": "That address is on the server's own network, so it can't be downloaded from.",
|
||||
"generic": "That address can't be downloaded from.",
|
||||
"bad_port": "That link's port number isn't valid."
|
||||
"malformed": "That link isn't a valid web address."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
|
||||
@@ -25,6 +25,5 @@
|
||||
"title": "All set",
|
||||
"body": "Read and unsubscribe access are both granted. You can review or revoke either one anytime in Settings → Account, or from your Google Account.",
|
||||
"done": "Done"
|
||||
},
|
||||
"importFailed": "Couldn't import your subscriptions. You can try again from Statistics."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
"unresolvable": "Ezt a címet nem sikerült feloldani. Ellenőrizd a hivatkozást, és próbáld újra.",
|
||||
"private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla.",
|
||||
"generic": "Erről a címről nem lehet letölteni.",
|
||||
"bad_port": "Ennek a hivatkozásnak érvénytelen a portszáma."
|
||||
"malformed": "Ez a hivatkozás nem érvényes webcím."
|
||||
},
|
||||
"fail": {
|
||||
"title": "A letöltés nem sikerült",
|
||||
|
||||
@@ -25,6 +25,5 @@
|
||||
"title": "Minden kész",
|
||||
"body": "Az olvasási és a leiratkozási hozzáférés is engedélyezve van. Bármelyiket bármikor áttekintheted vagy visszavonhatod a Beállítások → Fiók menüben, vagy a Google-fiókodban.",
|
||||
"done": "Kész"
|
||||
},
|
||||
"importFailed": "Nem sikerült importálni a feliratkozásaidat. Újrapróbálhatod a Statisztika oldalon."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// jsdom because the shared module reaches lib/api, which pulls in i18n's localStorage probe.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { invalidateUserAccess, isEmailOff } from "./adminUsers";
|
||||
import { HttpError } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// The set matters, not the call: letting someone in changes three things a user can see at once —
|
||||
// the row, the pending queue, and the /api/me badge — and the account panel shipped invalidating
|
||||
// only the first two, so the badge went on claiming the person was stuck until a reload. Pinning the
|
||||
// keys here is what stops the two call sites drifting apart again, since neither component test
|
||||
// asserts on invalidation.
|
||||
describe("invalidateUserAccess", () => {
|
||||
it("refreshes the row, the pending queue and the badge", () => {
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries").mockImplementation(() => Promise.resolve());
|
||||
|
||||
invalidateUserAccess(qc);
|
||||
|
||||
const keys = spy.mock.calls.map(([a]) => JSON.stringify(a?.queryKey));
|
||||
expect(keys).toEqual(
|
||||
[qk.adminUsers(), qk.adminPendingAccess(), qk.me()].map((k) => JSON.stringify(k)),
|
||||
);
|
||||
});
|
||||
|
||||
it("also refreshes one account's gate readout when given an id", () => {
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries").mockImplementation(() => Promise.resolve());
|
||||
|
||||
invalidateUserAccess(qc, 7);
|
||||
|
||||
expect(spy.mock.calls.map(([a]) => JSON.stringify(a?.queryKey))).toContain(
|
||||
JSON.stringify(qk.adminUserAccess(7)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The admin-side resend deliberately reports failures (the public one is silent), so it has to be
|
||||
// able to say WHICH — a no-SMTP instance is a configuration answer, not a bug.
|
||||
describe("isEmailOff", () => {
|
||||
it("recognises the no-mail refusal", () => {
|
||||
expect(isEmailOff(new HttpError(409, "x", { code: "resend", reason: "email_off" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for any other failure, including a non-HttpError", () => {
|
||||
expect(isEmailOff(new HttpError(422, "x", { code: "resend", reason: "send_failed" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isEmailOff(new HttpError(500, "x", null))).toBe(false);
|
||||
expect(isEmailOff(new TypeError("Failed to fetch"))).toBe(false);
|
||||
expect(isEmailOff(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,11 @@ import { qk } from "./queryKeys";
|
||||
// Grouped invalidation lives per domain rather than in `qk` (see the contract note there);
|
||||
// `invalidateDownloads` is the same pattern for the downloads domain.
|
||||
|
||||
/** Everything that goes stale when a user's ACCESS state changes (activate, approve, suspend).
|
||||
/** Everything that goes stale when an account is LET IN (activate, approve an invite).
|
||||
*
|
||||
* Suspend and delete move the same counters and are deliberately NOT wired here yet — they have
|
||||
* their own pre-existing staleness, tracked separately, and widening this in passing would put an
|
||||
* unreviewed change in a review-fix diff.
|
||||
*
|
||||
* `me` is in the set because the admin badge counts pending activations, and `admin-pending-access`
|
||||
* because the queue is exactly the list of accounts this action removes one from — refreshing only
|
||||
|
||||
@@ -59,15 +59,7 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
// Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a
|
||||
// same-document navigation the import-time snapshot never sees. The verify half got this
|
||||
// listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17).
|
||||
if (hash.get("reset")) {
|
||||
setResetPending(true);
|
||||
// Same rule as a second verify link below: a newly pasted link must not be shadowed by a
|
||||
// banner still standing from the previous one. Without this the reset guidance is
|
||||
// unreachable whenever an unread verify outcome is on screen, and `dismiss` then clears the
|
||||
// pending flag along with the outcome — the DX-17 silence, one state later.
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
}
|
||||
if (hash.get("reset")) setResetPending(true);
|
||||
};
|
||||
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
|
||||
// feed itself.
|
||||
@@ -82,12 +74,9 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
clearEntryUrl();
|
||||
if (!token || consumed.current.has(token)) return;
|
||||
consumed.current.add(token);
|
||||
// A second link arriving over an unread first outcome must not leave the OLD banner standing —
|
||||
// whichever kind that outcome was. Clearing both keeps the two states mutually exclusive, which
|
||||
// is what lets the single `dismiss` below clear "whatever is showing" without eating the other.
|
||||
// A second link arriving over an unread first outcome must not leave the OLD banner standing.
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
setResetPending(false);
|
||||
api
|
||||
.verifyEmail(token)
|
||||
.then((r) => {
|
||||
@@ -112,9 +101,18 @@ export function useVerifyEntry(): VerifyEntry {
|
||||
outcome,
|
||||
email,
|
||||
resetPending,
|
||||
// Dismiss peels ONE layer. The two states are independent — a reset link can arrive over an
|
||||
// unread confirmation, and a slow /auth/verify can resolve after one did — so clearing both at
|
||||
// once silently discards whichever the user had not read yet. The banner shows the reset
|
||||
// guidance first (it is the one with something to do), and dismissing it reveals the outcome
|
||||
// underneath rather than taking it with it.
|
||||
dismiss: () => {
|
||||
if (resetPending) {
|
||||
setResetPending(false);
|
||||
return;
|
||||
}
|
||||
setOutcome(null);
|
||||
setResetPending(false);
|
||||
setEmail(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
<>
|
||||
<div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>
|
||||
<div data-testid="reset">{entry.resetPending ? "reset-pending" : "-"}</div>
|
||||
<button data-testid="dismiss" onClick={entry.dismiss} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -157,11 +158,10 @@ describe("confirming an emailed verification link", () => {
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
});
|
||||
|
||||
// The two banners are mutually exclusive by construction, and each new link clears the other. Once
|
||||
// they could coexist, the reset guidance was gated behind "no outcome showing" and a standing
|
||||
// confirmation swallowed it — DX-17 again, one state later — and dismissing that confirmation then
|
||||
// cleared the pending reset with it, so the guidance could never appear at all.
|
||||
it("lets a pasted reset link supersede a confirmation banner still on screen", async () => {
|
||||
// A reset link can arrive over an unread confirmation (and a slow /auth/verify can resolve after
|
||||
// one did), so both states can be set at once. Neither may eat the other: `dismiss` used to clear
|
||||
// both, which is how the reset guidance could be destroyed before it was ever rendered.
|
||||
it("keeps a pasted reset link and an unread confirmation, and dismisses them one at a time", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-first", verifyEmail);
|
||||
@@ -172,25 +172,18 @@ describe("confirming an emailed verification link", () => {
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
// Both live: the reset is pending AND the confirmation is still unread.
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
expect(outcome()).toBe("none:-"); // the old banner is gone, so nothing shadows the guidance
|
||||
});
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
|
||||
it("lets a pasted verify link supersede a pending reset notice", async () => {
|
||||
// The other direction, for the same reason: whichever link arrived last is the one the user is
|
||||
// acting on, and only one of the two states may be true for `dismiss` to be unambiguous.
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "second@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#reset=first", verifyEmail);
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#verify=tok-second");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(outcome()).toBe("ok:second@example.com");
|
||||
// One dismiss peels the reset guidance and leaves the confirmation standing…
|
||||
await act(async () => screen.getByTestId("dismiss").click());
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
|
||||
// …and the next one clears that.
|
||||
await act(async () => screen.getByTestId("dismiss").click());
|
||||
expect(outcome()).toBe("none:-");
|
||||
});
|
||||
|
||||
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
|
||||
|
||||
Reference in New Issue
Block a user