All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import { screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { Route } from "react-router";
|
|
import { Dump } from "../../pages/Dump.tsx";
|
|
import { DumpEdit } from "../../pages/DumpEdit.tsx";
|
|
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
|
|
import { calls, fail, route } from "../fetch.ts";
|
|
import {
|
|
makeCategory,
|
|
makeComment,
|
|
makeDump,
|
|
makePlaylist,
|
|
makeSession,
|
|
makeUser,
|
|
wire,
|
|
} from "../fixtures.ts";
|
|
import { currentPath, renderApp } from "../render.tsx";
|
|
import { FakeWebSocket } from "../ws.ts";
|
|
|
|
const owner = makeSession({ id: "owner", username: "owner" });
|
|
const other = makeSession({ id: "other", username: "other" });
|
|
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
|
|
|
function dumpRoutes(
|
|
dump = makeDump({
|
|
id: "d1",
|
|
slug: "a-find",
|
|
title: "A find",
|
|
userId: "owner",
|
|
comment: "Because **reasons**",
|
|
categoryIds: ["c1"],
|
|
}),
|
|
) {
|
|
route("GET /api/dumps/:id", () => wire(dump));
|
|
route(
|
|
"GET /api/users/by-id/:id",
|
|
() => wire(makeUser({ id: "owner", username: "owner" })),
|
|
);
|
|
route("GET /api/dumps/:id/comments", () => []);
|
|
route("GET /api/dumps/:id/related", () => []);
|
|
route("GET /api/dumps/:id/playlists", () => []);
|
|
return dump;
|
|
}
|
|
|
|
function openDump(
|
|
session: ReturnType<typeof makeSession> | null = null,
|
|
url = "/dumps/a-find",
|
|
) {
|
|
const user = userEvent.setup();
|
|
renderApp(<Dump />, {
|
|
route: url,
|
|
path: "/dumps/:selectedDump",
|
|
session,
|
|
categories: [music],
|
|
extraRoutes: (
|
|
<Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />
|
|
),
|
|
});
|
|
const socket = FakeWebSocket.last;
|
|
socket.welcome();
|
|
return { user, socket };
|
|
}
|
|
|
|
describe("Dump page", () => {
|
|
test("shows the dump, its author, categories and note", async () => {
|
|
dumpRoutes();
|
|
openDump();
|
|
expect(await screen.findByRole("heading", { name: "A find" }))
|
|
.toBeInTheDocument();
|
|
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute(
|
|
"href",
|
|
"/users/owner",
|
|
);
|
|
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
|
"href",
|
|
"/music",
|
|
);
|
|
expect(screen.getByText("reasons").tagName).toBe("STRONG");
|
|
await waitFor(() => expect(document.title).toMatch(/^A find · /));
|
|
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find");
|
|
});
|
|
|
|
test("a missing dump is an error page", async () => {
|
|
route("GET /api/dumps/:id", () => fail("Dump not found", 404));
|
|
route("GET /api/dumps/:id/comments", () => []);
|
|
route("GET /api/dumps/:id/related", () => []);
|
|
route("GET /api/dumps/:id/playlists", () => []);
|
|
openDump();
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
"Dump not found",
|
|
);
|
|
expect(screen.getByRole("button", { name: "← Back to all dumps" }))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
test("the owner can rename it in place, following the new slug", async () => {
|
|
dumpRoutes();
|
|
route(
|
|
"PUT /api/dumps/:id",
|
|
() =>
|
|
wire(
|
|
makeDump({
|
|
id: "d1",
|
|
slug: "a-better-find",
|
|
title: "A better find",
|
|
userId: "owner",
|
|
}),
|
|
),
|
|
);
|
|
const { user } = openDump(owner);
|
|
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
|
const input = screen.getByDisplayValue("A find");
|
|
await user.clear(input);
|
|
await user.type(input, "A better find{Enter}");
|
|
expect(await screen.findByRole("heading", { name: "A better find" }))
|
|
.toBeInTheDocument();
|
|
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
|
|
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
|
|
title: "A better find",
|
|
});
|
|
});
|
|
|
|
test("a refused rename says why; Escape backs out", async () => {
|
|
dumpRoutes();
|
|
route("PUT /api/dumps/:id", () => fail("Title taken", 400));
|
|
const { user } = openDump(owner);
|
|
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
|
await user.type(screen.getByDisplayValue("A find"), "!{Enter}");
|
|
expect(await screen.findByText("Title taken")).toBeInTheDocument();
|
|
await user.keyboard("{Escape}");
|
|
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
|
|
});
|
|
|
|
test("others can't rename or edit it", async () => {
|
|
dumpRoutes();
|
|
const { user } = openDump(other);
|
|
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
|
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("link", { name: "Edit" })).not
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
test("the owner gets an Edit link", async () => {
|
|
dumpRoutes();
|
|
const { user } = openDump(owner);
|
|
await user.click(await screen.findByRole("link", { name: "Edit" }));
|
|
expect(currentPath()).toBe("/dumps/a-find/edit");
|
|
});
|
|
|
|
test("the title can be edited from the keyboard", async () => {
|
|
dumpRoutes();
|
|
const { user } = openDump(owner);
|
|
await screen.findByRole("heading", { name: "A find" });
|
|
screen.getByTitle("Edit title").focus();
|
|
await user.keyboard("{Enter}");
|
|
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
|
|
});
|
|
|
|
test("votes go through the socket", async () => {
|
|
dumpRoutes(
|
|
makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }),
|
|
);
|
|
const { user, socket } = openDump(other);
|
|
await user.click(await screen.findByRole("button", { name: "Upvote" }));
|
|
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
|
|
expect(screen.getByRole("button", { name: "Remove vote" }))
|
|
.toHaveTextContent("3");
|
|
});
|
|
|
|
test("comments load, and live ones arrive", async () => {
|
|
dumpRoutes();
|
|
route(
|
|
"GET /api/dumps/:id/comments",
|
|
() => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]),
|
|
);
|
|
const { socket } = openDump(other);
|
|
await screen.findByText("first");
|
|
socket.receive({
|
|
type: "comment_created",
|
|
comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })),
|
|
});
|
|
expect(screen.getByText("second")).toBeInTheDocument();
|
|
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
|
|
expect(screen.getByText("[deleted]")).toBeInTheDocument();
|
|
// Another dump's comment isn't shown here.
|
|
socket.receive({
|
|
type: "comment_created",
|
|
comment: wire(
|
|
makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" }),
|
|
),
|
|
});
|
|
expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("related dumps and the playlists it's in", async () => {
|
|
dumpRoutes();
|
|
route(
|
|
"GET /api/dumps/:id/related",
|
|
() => wire([makeDump({ title: "Links here" })]),
|
|
);
|
|
route(
|
|
"GET /api/dumps/:id/playlists",
|
|
() => wire([makePlaylist({ title: "Best of" })]),
|
|
);
|
|
openDump();
|
|
expect(await screen.findByRole("heading", { name: "Related" }))
|
|
.toBeInTheDocument();
|
|
expect(screen.getByText("Links here")).toBeInTheDocument();
|
|
expect(await screen.findByText("Best of")).toBeInTheDocument();
|
|
});
|
|
|
|
test("a live update of this dump is applied", async () => {
|
|
const d = dumpRoutes();
|
|
const { socket } = openDump();
|
|
await screen.findByRole("heading", { name: "A find" });
|
|
socket.receive({
|
|
type: "dump_updated",
|
|
dump: wire({ ...d, title: "Retitled" }),
|
|
});
|
|
expect(screen.getByRole("heading", { name: "Retitled" }))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
test("adding to a playlist", async () => {
|
|
dumpRoutes();
|
|
route("GET /api/playlists/by-dump/:id/memberships", () => [
|
|
{
|
|
playlist: wire(makePlaylist({ id: "p", title: "Mine" })),
|
|
hasDump: true,
|
|
},
|
|
]);
|
|
route(
|
|
"DELETE /api/playlists/:pid/dumps/:did",
|
|
() => new Response(null, { status: 204 }),
|
|
);
|
|
const { user } = openDump(owner);
|
|
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
|
|
const row = await screen.findByRole("checkbox", { name: /Mine/ });
|
|
expect(row).toHaveAttribute("aria-checked", "true");
|
|
await user.click(row);
|
|
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "false"));
|
|
});
|
|
|
|
test("a new playlist can be made from there, with the dump in it", async () => {
|
|
dumpRoutes();
|
|
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
|
route(
|
|
"POST /api/playlists",
|
|
(req) =>
|
|
wire(
|
|
makePlaylist({
|
|
id: "np",
|
|
title: (req.body as { title: string }).title,
|
|
}),
|
|
),
|
|
);
|
|
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
|
const { user } = openDump(owner);
|
|
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "+ New playlist" }),
|
|
);
|
|
await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
|
|
const row = await screen.findByRole("checkbox", { name: /Fresh/ });
|
|
expect(row).toHaveAttribute("aria-checked", "true");
|
|
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
|
"/api/playlists/np/dumps/d1",
|
|
);
|
|
});
|
|
|
|
test("scrolls to a linked comment", async () => {
|
|
dumpRoutes();
|
|
route(
|
|
"GET /api/dumps/:id/comments",
|
|
() => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]),
|
|
);
|
|
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
|
|
openDump(null, "/dumps/a-find#comment-c9");
|
|
await screen.findByText("linked");
|
|
await waitFor(() =>
|
|
expect(document.getElementById("comment-c9")).toHaveClass(
|
|
"comment-node--highlight",
|
|
)
|
|
);
|
|
expect(scroll).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("DumpEdit page", () => {
|
|
function openEdit(dump: ReturnType<typeof makeDump>, session = owner) {
|
|
route("GET /api/dumps/:id", () => wire(dump));
|
|
const user = userEvent.setup();
|
|
renderApp(
|
|
<RestrictedLoggedIn>
|
|
<DumpEdit />
|
|
</RestrictedLoggedIn>,
|
|
{
|
|
route: `/dumps/${dump.slug ?? dump.id}/edit`,
|
|
path: "/dumps/:selectedDump/edit",
|
|
session,
|
|
categories: [music],
|
|
extraRoutes: (
|
|
<>
|
|
<Route path="/dumps/:selectedDump" element={<p>dump page</p>} />
|
|
<Route path="/" element={<p>home</p>} />
|
|
</>
|
|
),
|
|
},
|
|
);
|
|
FakeWebSocket.last.welcome();
|
|
return user;
|
|
}
|
|
|
|
const urlDump = () =>
|
|
makeDump({
|
|
id: "d1",
|
|
slug: "a-find",
|
|
title: "A find",
|
|
userId: "owner",
|
|
url: "https://a.test/",
|
|
comment: "old why",
|
|
categoryIds: [],
|
|
richContent: {
|
|
type: "link",
|
|
url: "https://a.test/",
|
|
title: "Page's own title",
|
|
},
|
|
});
|
|
|
|
test("saves title, note, categories and visibility", async () => {
|
|
route(
|
|
"PUT /api/dumps/:id",
|
|
() => wire(makeDump({ id: "d1", slug: "new-title" })),
|
|
);
|
|
const user = openEdit(urlDump());
|
|
const title = await screen.findByLabelText("Title");
|
|
expect(document.title).toMatch(/^Editing A find/);
|
|
await user.clear(title);
|
|
await user.type(title, "New title");
|
|
await user.click(screen.getByRole("button", { name: "Music" }));
|
|
await user.click(screen.getByRole("button", { name: "Private" }));
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await screen.findByText("dump page");
|
|
expect(currentPath()).toBe("/dumps/new-title");
|
|
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
|
|
url: "https://a.test/",
|
|
title: "New title",
|
|
comment: "old why",
|
|
isPrivate: true,
|
|
categoryIds: ["c1"],
|
|
});
|
|
});
|
|
|
|
test("clearing the note removes it", async () => {
|
|
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" })));
|
|
const user = openEdit(urlDump());
|
|
await user.clear(await screen.findByLabelText("Why?"));
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await screen.findByText("dump page");
|
|
expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null);
|
|
});
|
|
|
|
test("the title resets to the page's own", async () => {
|
|
const user = openEdit(urlDump());
|
|
const title = await screen.findByLabelText("Title");
|
|
await user.click(screen.getByRole("button", { name: "Reset to default" }));
|
|
expect(title).toHaveValue("Page's own title");
|
|
expect(screen.getByRole("button", { name: "Reset to default" }))
|
|
.toBeDisabled();
|
|
});
|
|
|
|
test("a refused save stays on the form", async () => {
|
|
route("PUT /api/dumps/:id", () => fail("Invalid URL", 400));
|
|
const user = openEdit(urlDump());
|
|
await user.click(await screen.findByRole("button", { name: "Save" }));
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert).toHaveTextContent("Could not save");
|
|
expect(alert).toHaveTextContent("Invalid URL");
|
|
});
|
|
|
|
test("refreshing the metadata", async () => {
|
|
route(
|
|
"POST /api/dumps/:id/refresh-metadata",
|
|
() =>
|
|
wire({
|
|
...urlDump(),
|
|
richContent: {
|
|
type: "link",
|
|
url: "https://a.test/",
|
|
title: "Fresh title",
|
|
description: "New blurb",
|
|
},
|
|
}),
|
|
);
|
|
const user = openEdit(urlDump());
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Refresh metadata" }),
|
|
);
|
|
expect(await screen.findByText("New blurb")).toBeInTheDocument();
|
|
});
|
|
|
|
test("replacing a file sends a visibility change in the same request", async () => {
|
|
const fileDump = makeDump({
|
|
id: "f1",
|
|
kind: "file",
|
|
title: "Song",
|
|
userId: "owner",
|
|
fileName: "song.mp3",
|
|
fileMime: "audio/mpeg",
|
|
fileSize: 10,
|
|
url: undefined,
|
|
isPrivate: false,
|
|
});
|
|
route(
|
|
"PUT /api/dumps/:id/file",
|
|
() => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }),
|
|
);
|
|
const user = openEdit(fileDump);
|
|
await screen.findByText("song.mp3");
|
|
const input = document.querySelector<HTMLInputElement>(
|
|
'.fdz input[type="file"]',
|
|
)!;
|
|
await user.upload(
|
|
input,
|
|
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Private" }));
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await screen.findByText("dump page");
|
|
const [put] = calls("PUT /api/dumps/:id/file");
|
|
const form = put.body as FormData;
|
|
expect(form.get("file")).toBeInstanceOf(File);
|
|
expect(form.get("isPrivate")).toBe("true");
|
|
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
|
|
});
|
|
|
|
test("a failed file replacement stays on the form, with nothing half-saved", async () => {
|
|
const fileDump = makeDump({
|
|
id: "f1",
|
|
kind: "file",
|
|
title: "Song",
|
|
userId: "owner",
|
|
fileName: "song.mp3",
|
|
fileMime: "audio/mpeg",
|
|
fileSize: 10,
|
|
url: undefined,
|
|
isPrivate: false,
|
|
});
|
|
route("PUT /api/dumps/:id/file", () => fail("File too large (max 50 MB)"));
|
|
const user = openEdit(fileDump);
|
|
await screen.findByText("song.mp3");
|
|
const input = document.querySelector<HTMLInputElement>(
|
|
'.fdz input[type="file"]',
|
|
)!;
|
|
await user.upload(
|
|
input,
|
|
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Private" }));
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
expect(await screen.findByText("File too large (max 50 MB)"))
|
|
.toBeInTheDocument();
|
|
expect(screen.queryByText("dump page")).not.toBeInTheDocument();
|
|
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
|
|
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
|
|
});
|
|
|
|
test("deleting asks, then goes home", async () => {
|
|
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
|
|
const user = openEdit(urlDump());
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Delete dump" }),
|
|
);
|
|
await user.click(
|
|
within(screen.getByRole("alertdialog")).getByRole("button", {
|
|
name: "Delete dump",
|
|
}),
|
|
);
|
|
await screen.findByText("home");
|
|
expect(
|
|
JSON.parse(
|
|
document.querySelector('[data-testid="location"]')!.getAttribute(
|
|
"data-state",
|
|
)!,
|
|
),
|
|
)
|
|
.toEqual({ deletedDumpId: "d1" });
|
|
});
|
|
|
|
test("a delete that can't reach the server says so", async () => {
|
|
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
|
|
const user = openEdit(urlDump());
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Delete dump" }),
|
|
);
|
|
await user.click(
|
|
within(screen.getByRole("alertdialog")).getByRole("button", {
|
|
name: "Delete dump",
|
|
}),
|
|
);
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
/Could not connect/,
|
|
);
|
|
});
|
|
|
|
test("a thumbnail that won't upload says so", async () => {
|
|
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
|
|
const user = openEdit(urlDump());
|
|
await screen.findByLabelText("Title");
|
|
const picker = document.querySelector<HTMLInputElement>(
|
|
'.dump-edit-thumbnail-row input[type="file"]',
|
|
)!;
|
|
await user.upload(
|
|
picker,
|
|
new File([new Uint8Array(3)], "t.png", { type: "image/png" }),
|
|
);
|
|
expect(await screen.findByText("Not an image")).toBeInTheDocument();
|
|
});
|
|
|
|
test("guests are sent to log in", () => {
|
|
renderApp(
|
|
<RestrictedLoggedIn>
|
|
<DumpEdit />
|
|
</RestrictedLoggedIn>,
|
|
{
|
|
route: "/dumps/x/edit",
|
|
path: "/dumps/:selectedDump/edit",
|
|
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
|
},
|
|
);
|
|
expect(screen.getByText("login page")).toBeInTheDocument();
|
|
});
|
|
});
|