dashboard/tests/unit
Component and route-handler tests for the Next.js dashboard. No browser, no containers, no network —
everything is mocked at the fetch boundary.
cd dashboard
npm test # vitest
npm test -- --watch
npm test -- settings-page
Stack: vitest, @testing-library/react, @testing-library/jest-dom, jsdom.
Pages
| File | Covers |
|---|---|
pipeline-client.test.tsx | PipelineClient — the Kanban board's data loading, polling and action dispatch, including that the OpenAI-key state read from /settings reaches ActionPane |
library-page.test.tsx | The Library page, including its search and strategy filters. It passes the modal publishingEnabled from /settings, off until that answers and on a failed read: the Library lists exactly the videos Publish…, Retry publishing and Cancel schedule apply to, and used to pass nothing, so none of them appeared there |
projects-page.test.tsx | The Projects page — project list and the videos filed under each, and that the active/paused badge appears on curriculum seeds only. Pause does not exist on any other domain, so a badge there would describe a state nothing can change. The "expired" badge, by contrast, appears on any domain at its topic cap |
projects-page-action-extra.test.tsx | That the Projects page forwards the modal's fourth extra argument into the approval PATCH. It captures the onAction the page hands the modal and invokes it exactly as the Approve Topic button does. Worth its own file because the failure was invisible: the modal renders the pickers on that page, the request succeeds, and the picks are simply absent — and since all four are frozen at approval, they can never be applied afterwards. It also checks that the page hands the modal the voice roster from /settings, since without it the modal's voice pickers never render there. It also passes the modal publishingEnabled from the same /settings read as the voice roster, which used to drop it |
projects-page-agent-picker.test.tsx | The Projects page with the real VideoModal, not a stub, on the path where nobody touches the agent picker. The page fetches /agents, so the modal offers live agents there. Approving a topic sends no agent_id, and approving a script sends no agent field at all, both before and after /agents answers — a guessed value submitted as an explicit one is the defect this exists for, and a live test that always touched the picker could not catch it. A picked agent does reach the PATCH, with that agent's default model, as the negative control |
topics-page.test.tsx | The Topics page: mining sources, and the domain pause control. A refused mining save shows the API's own message, which names the bad entries, rather than a generic failure, and a successful one lists what the API stored rather than what the page sent. The control and its badge appear only on a curriculum seed and carry the "Pause topic generation" label. A plain "Pause" is what made the broader reading, stopping a subject everywhere, look plausible. A refused pause is alerted rather than shown as success. The Mining toggle is greyed out with its reason on hover when GET /research/strategies reports mining_available: false, and usable when it reports true; a refused toggle shows the API's message and leaves the toggles as they were, since nothing was stored. Also that Illustrated is disabled in both default-style pickers (the domain row's and the add form's) when /settings reports no OpenAI key, and offered when one is stored. Each domain row and the add form carry a default-voice picker labelled with each voice's model, and a default that has left the roster stays visible as "(removed)" rather than reading as the install default. POST /projects is a full replace, so every edit re-sends every field: changing the voice keeps the style, and changing the style keeps the voice. A field missing from the payload is silently cleared by any other edit. The third picker, the default script profile, offers only READY profiles, keeps a default that is no longer offered visible as "Unavailable profile" (never its UUID) or, when the list still names it, as that name marked unavailable, is re-sent by a style change, and is sent by the add form. It is capped at 220px on the row and in the add form, because profile names have no length limit and an unbounded select pushed the controls after it off-screen. A label test resolves it by its caption: three unlabelled dropdowns side by side read as one control. The topic cap field shows the cap with its 25–750 bounds and "N filed" beside it, saves only on blur (not per keystroke) and re-sends every other field, reverts an out-of-range, fractional, blank or non-numeric entry without saving, shows EXPIRED on any domain at its cap, seed or not, and shows it at once when the cap is lowered to the count. The count and expiry shown after a cap edit come from the row the API returns, never worked out from the count the page loaded: research may have filed in between, and a stale count once left a row reading open while the API refused research. An expired seed hides its ACTIVE badge (the two side by side read as a contradiction) but keeps PAUSED and the pause button. The voice and profile tests' exact payloads include topic_cap, since a full replace that dropped it would reset the cap to 100 on every other edit. The add form's description is a vertically resizable textarea; its interior newlines reach the POST /projects body intact with only the ends trimmed, input stops at 2000 characters and the counter reads 2000 / 2000, and 2000 emoji fit, because the limit counts code points as the API does |
new-video-page.test.tsx | The New Video page's POST body. An untouched picker must send the value that means "the default", never a guess: no agent_id under Automatic (so the API deals by weight), agent_model "" there but the picked agent's default once one is picked (pinned, as topic approval pins it), and agent_effort "". Also covered: only live agents and READY profiles are offered; Illustrated is disabled with no OpenAI key; submit is blocked with a blank title or topic or a title over 70, counted in code points as the API counts it, so an emoji is one character; a refusal shows the API's reason and keeps the input; a success clears the form. The voice picker's "Default" names the voice the API will actually seed: the manual domain's default while it is still in the roster, else the install default |
script-profiles-page.test.tsx | The Script Profiles page — listing, the create form, and the empty state |
settings-page.test.tsx | The Settings page — tab structure, API-key fields, the agent-model and agent-effort controls, the Discord notification tab, the AI Review tab's Experimental label (in its heading and beside the switch, whose note names the fail-open behaviour), the Images tab with no provider control — the scene-image model lists the OpenAI catalog and saves as image_model alone — and its character-model picker (the same catalog, saved as character_model alone), the Scheduling tab's two agent-session fields (the timeout shown in minutes and saved in seconds), the Preferences tab's textbox and character counter (which counts code points like the API, so an emoji is one character, not two), the Retention tab's failed-and-rejected field and its backlog line (left out, not an error, when /retention/backlog fails, so the field stays usable), and save behaviour. The effort select is pinned as usable even for an agent with an empty models list: its levels come from the CLI rather than from what this install probed, so a broken model list must not also take the effort control away. There is no install-wide model or pronunciation-mode select: each voice row picks its own model from the full catalog, the add row picks the new voice's, and a save carries the roster with the model on it and never an elevenlabs_model_id or pronunciation_mode key. The preview sends the row's model, so an unsaved change is what plays. The legend tells the reader that eleven_v3 and eleven_v4 take exact IPA. The add-voice row shows below the 10-voice cap and gives way to a cap note at it. The tab list it checks includes Auto Pilot. |
costs-panel.test.tsx | Settings → Costs. Each model names where its rate came from — and says so when it has none; rates are shown per million tokens; the last successful sync and a failed one are both visible, so stale prices never look current; Sync now posts to the sync route; an unpriced model can be given a price, typed per million tokens and sent per token; and only a manual rate offers Reset |
auto-pilot-panel.test.tsx | Settings → Auto Pilot. Auto Pilot spends money and posts in public with nobody watching, so these guard what keeps turning it on a deliberate act. The whole warning is always on screen. Saving a change that widens what it acts on — turning it on, or adding a project while it is on — opens a confirmation naming how many videos are approved at once (checkpoints only; videos already at Video Approved are not approvals), and nothing is sent until it is confirmed; cancelling keeps the edits unsaved. A change that widens nothing saves without asking. Only accounts that are both allowed and connected are offered, and an account chosen earlier that is no longer offered stays visible and ticked so it can be removed (hidden, it would be resent on every save); a selected project with no account, and an install with publishing off, both say the videos will stop at Video Approved. The gap is shown in hours when it is whole hours and sent in minutes. A refused save shows the API's reason and keeps the edits. A response that is not a configuration is never rendered as one: other suites' fetch mocks, and a proxy error in production, answer with some other object, and showing that as "off, no projects" would let a Save wipe the real configuration. The in-progress limit is sent with the rest, and a value out of range is ignored; at the limit the tab says why nothing new starts ("Paused — 10 of 10 videos in progress"), because an Auto Pilot that starts nothing for no stated reason is indistinguishable from a broken one. Below it the line only counts, a full limit is called a pause only while Auto Pilot is on, and with no limit there is no line. The help text tells you to size the limit in days of posting, because a number sized in posts is a day of cover or less. The count is re-read while the tab stays open, since videos publish behind it; the refresh changes only the count, so an unsaved edit survives it and stays savable |
socials-page.test.tsx | The Socials page — accounts grouped by platform, which are allowlisted, and that the count reflects only the enabled ones. Saving sends ids only, since display data is hydrated live and a stored username would be a second copy going stale. Also the two badges: an allowlisted account gone upstream is flagged and stays untickable-only, which is the only way a stale entry can be cleaned out. The Connect buttons: one per platform. The tab opens inside the click, because a popup blocker allows window.open only during the gesture, and the URL cannot be fetched ahead since its state is single-use. The tab is then pointed at the minted URL, or closed on a failure, which shows postforme's own message (a platform not enabled on the postforme project). One connect runs at a time, and a blocked tab falls back to a link. A connect error that only repeats the page's own error (publishing off or no key: the list and the connect fail with the same 503) is not shown twice, while a different one still is |
analytics-page.test.tsx | The Analytics page — cross-platform totals, that it counts published videos rather than publications, a row per publication linked to its live post, and platform and account filters applied through the API rather than in the browser. Pins that an unreported metric renders — and never 0: "not reported" and "reported as zero" are different claims. The page has Videos and Spend tabs: Videos is the default, and the spend card mounts only when Spend is opened, so a visit does not fetch spend. The sort puts the highest value first and an unreported metric below a real 0, leaving the API's newest-first order untouched by default. The account filter reads demo_channel (Instagram) from /publishing/accounts?feeds=0, and when that lookup fails, or names an account postforme no longer has, it falls back to the id plus the platform, still filters by id, and shows no analytics error. Clicking a platform or account bar filters through the API, like the menus, and the month graph is drawn from by_month while a response without it still renders. The table renders 50 rows, adds 50 each time the sentinel at its end is reported in view — observed against the table's own scroll box, not the page — starts again from 50 when the sort changes, and renders every row where IntersectionObserver does not exist, since nothing could ask for more there |
analytics-charts.test.tsx | The Analytics page's graphs. Bars run highest first, scaled against the largest, with an entry nothing was reported for placed last as — and no bar — an empty bar would claim nobody watched, the same "not reported is not 0" rule as the table. Accounts are named demo_channel (Instagram), falling back to the id with the platform. Clicking a bar calls the filter with its key, and clicking the selected bar clears it; every bar and month column carries its exact figure on hover. A month where views fell keeps its negative figure and draws no height, and each graph says when it has nothing to draw |
spend-card.test.tsx | The Analytics page's Spend tab — the only place spend that belongs to no video (research, mining, previews, the unscoped fallback) is visible. Unpriced calls are called out beside the total rather than folded in as $0; the ElevenLabs credit pool is shown when the key can read it and recorded credits otherwise, since user_read is an optional permission; an error response shows an error rather than a blank card; and the API-list-price disclaimer is present. The range: presets are sent by name (?preset=month) with no dates of the browser's own — TimeZoneProvider is UTC until the host zone arrives, so a browser-derived "today" could name the wrong days — and the label shows the days the API echoes; a preset change re-fetches, Custom starts from the days that were showing, a backwards or half-empty custom range is flagged and never sent — and the previous range's figures are hidden rather than left under the new label — and the pool is labelled as the billing period, since it does not follow the range |
archive-page.test.tsx | The Archive page — archived videos newest-first, requested as archived rather than filtered client-side. Guards that a deleted video keeps its publication links and summed views, which is what makes analytics survive the retention sweep, and that a video with nothing reported shows — rather than 0 |
logs-page.test.tsx | The Logs page — records rendered in the order the API returned them, service and level options taken from the response rather than a hardcoded local list, no service parameter sent until one is picked, and level/search/limit passed on the query string. Refresh is on demand: pins that there is no polling or live tail. An API error surfaces as an error, never as an empty list, which would read as "no logs" |
Components
| File | Covers |
|---|---|
action-pane.test.tsx | ActionPane — per-status buttons, the script-profile picker, the agent-model input, the agent-effort picker, the visual-style picker, and that frozen choices stay visible after the topic checkpoint. The effort tests pin that on the row it is greyed out with the model while the agent is unusable, and comes back when an installed agent is picked from the still-live agent dropdown. The reason for the grey is hover text only: the row carries no not installed line, which used to wrap the buttons. And — the regression that matters — that an untouched approval submits "" rather than a guessed level. Projects and Library never fetch /agents at all, so resolving the picker to a literal default and submitting it would silently override an agent configured for anything else, unfixably, since agent_effort freezes at approval. The style tests also pin that a video left on Auto shows what the agent actually chose and marks it automatic: without that the board cannot explain why two videos look different, which is the whole reason the resolution is recorded. The label tests resolve every picker by its caption (getByLabelText), proving the caption is the control's accessible name and not merely nearby text. The style picker disables Illustrated, labelled with why, when told no OpenAI key is stored, and leaves it enabled when not told: the API is the authority, so a page that never loads settings gets the API's clear refusal rather than a wrongly greyed option. The SCRIPT_READY voice picker labels each voice with its model and disables any voice the API lists in voice_conflicts, with the reason as its title; it warns when the video's own voice can no longer speak its script. The TOPIC_PROPOSED voice picker starts on the video's own voice, and an untouched Approve sends no voice_id — the negative control: sending the shown voice would pin the install default over a voice the video was seeded with from its project. A second group renders the row before /settings answers and again after: the shown voice, and the one approve-script sends, must follow the late default. Seeding the picker at mount froze "" there, so the row showed the roster's first voice while sending another. A voice no longer on the roster is listed as itself, disabled, rather than displayed as the first voice. A grouping-and-filter group feeds the pane a scrambled mix of statuses and pins that rows come out grouped in pipeline order with the API's order kept inside each group. A status pill hides its group, and toggling it back restores it (the negative control). Every status filtered out says no actions match, never INBOX ZERO, and keeps the pills, so the filter can be undone. The profile picker is capped at 220px like the voice picker, since profile names have no length limit. A curriculum video can arrive holding its domain's default profile, deleted since: the profile picker must show it as unavailable rather than display "No profile" while sending it, and must stay visible with no READY profile left so it can be cleared. A row that flashes away after an action leaves its pill count |
cost-breakdown.test.tsx | The modal's cost breakdown. The rule it exists for: a shared agent session shows this video's share with the whole session beside it, and the total adds the share — adding whole sessions would count one session once per video in its batch. Also that the expansion lists each session under the stage the video was at with the ÷N split written out (a session now covers one video and one stage, so a VOICING session is labelled voicing alone, while a pre-upgrade VOICING_WITH_SCENES row reads "Voicing & scene writing"; the split still matters for sessions recorded before), that ElevenLabs is credits and never dollars, that unpriced calls are counted and never shown as $0, that a video from before cost tracking says so instead of $0.00, that a malformed response is reported rather than rendered, and that the disclaimer is always there. The Agent row lists each model as its own unwrappable line: joined into one string, the narrow sidebar broke it after a hyphen and the dangling claude- read as part of the value column beside it. jsdom cannot see a wrap, so the test pins the one-element-per-name structure and the nowrap that prevents the break; the look itself needs a browser |
video-modal.test.tsx | VideoModal — timestamps rendered in the host timezone, reject vs. revise, the Script tab's segmented and legacy-flat rendering, the script-profile picker, and the agent-model and agent-effort pickers with their read-only rows after the freeze. One effort test opens a second video in the still-mounted modal: the seed-change effect is the only thing clearing per-video picker state, and a level picked for the first video would otherwise be inherited and written on the second's approval. The label tests resolve every Actions-block picker by its caption, scoped to the Actions block so a read-only row elsewhere in the modal cannot satisfy them. Its style picker disables Illustrated the same way, and leaves it enabled by default. The Script tab shows the authored word for <phoneme>, <respell> and <ipa> tags, never the markup, the respelling or the IPA. The voice picker disables a voice listed in voice_conflicts and explains a conflicting current voice. At TOPIC_PROPOSED it is offered too, and an untouched Approve Topic sends no voice_id; a voice picked for one video is forgotten when another is opened, for the same reason as the effort pick; and Approve Script sends a default that arrived after the modal opened. A held profile deleted since shows by name with "(deleted)" and stays clearable with no READY profile left, as on the row. A scheduled video says when it goes out, in the zone it was picked in and in the host's, even where no publishing controls are offered (the Library); its waiting targets read SCHEDULED rather than PENDING; Cancel schedule calls the API only after a confirm and shows a refusal inline; and it offers no Delete. The read-only Voice and Voice model rows show the API's voice_info as given, adding "(current setting)" only when the model was not the one voiced with, and "(not in roster)" for a removed voice. They are hidden exactly where the voice picker is up, and shown at the same status when no roster was handed in or the video is archived, since then no picker shows. The Video tab plays the video's own /stream/<project_id>/<id>.mp4 whatever video_path holds: a row naming another video's file must not play that file, and a legacy host path must still play. |
video-modal-retry-publish.test.tsx | VideoModal's Retry publishing action and canRetryPublish: offered only for a video that failed while publishing (FAILED with publication rows), never for a render failure, an archived video, or with publishing off. It refreshes in place when the server adopted a live post, and opens the Publish dialog when nothing was live |
video-modal-retry-agent.test.tsx | VideoModal's Retry agent action, isAgentStuck, and the Kanban card's STUCK badge. Offered only for a video the scheduled agent gave up on, never an archived one — and the stuck statuses are all agent statuses, where the modal otherwise shows no Actions block, so the block's own condition has to admit it. Retrying posts to agent-retry and refetches the video without touching its status; a refusal shows the server's reason |
publish-dialog.test.tsx | PublishDialog — that it offers only allowlisted, still-connected accounts and pre-ticks nothing, so every target is an explicit choice; that it refuses to publish with no target or an empty title; that it prefills the title and description the agent wrote with the script but still opens on the video's own title when that call fails; and that it sends the edited metadata with the picked targets. Publishing is irreversible, which is why the no-default rule is pinned Scheduling: the dialog opens on Publish now, and on every reopen, so a schedule left from another video cannot hold one back; the timezone defaults to the browser's own; nothing is sent until both a date and a time are picked; the body carries the wall time and the zone, never a UTC instant, because the API owns the conversion and DST; a preset fills the date in the picked zone; and the API's refusal shows inline. The zone used is one whose name is the same in the browser's list, which is CLDR's (Asia/Calcutta, not Asia/Kolkata). |
video-modal-delete.test.tsx | VideoModal's Delete action — confirmation required, fired exactly once, nothing deleted on cancel, and the Archived badge afterwards |
revise-dialog.test.tsx | ReviseDialog's geometry and submission — it sizes itself inline and stays narrower than the VideoModal beneath it, with a shrinkable grid column so a long title cannot widen it. Send stays disabled until there is non-blank text, and submits trimmed |
kanban-card.test.tsx | KanbanCard's display name, and its score: an unscored video (score 0, only ever from the New Video page) reads "—", never "0/10". Also scoreLabel, the shared helper the other score sites use A SCHEDULED card shows its time in the zone it was picked in; no other status shows one. |
summary-strip.test.tsx | SummaryStrip tile grouping — In Pipeline counts SCHEDULED videos. |
mining-sources.test.tsx | MiningSources — add and remove, and canonicalYouTubeUrl, the panel's copy of the API's URL rule: the same accepted forms come back as https://www.youtube.com/watch?v=<id>, and file://, internal addresses, look-alike hosts, userinfo, a port and a 10- or 12-character id are refused. Add appends the canonical form, so a second spelling of a listed video cannot be added. The rule is copied so the Add button lights only for what the API will store; a URL the API refuses anyway still comes back as its 400 |
voice-preview-button.test.tsx | VoicePreviewButton — request, playback and cached states. Play and pause are one button whose label follows the audio's own events (▶, then ❚❚ while playing, back to ▶ on pause or end), and a click while playing pauses rather than refetching. A model passed in is sent in a JSON body with its Content-Type (the proxy refuses a body without one), and a change of model or voice resets the button so the next play fetches a new sample. A slow response for a model no longer selected is dropped, never played, and a browser that blocks autoplay leaves the button on ▶ |
timezone-provider.test.tsx | TimeZoneProvider's degradation matrix — what the UI shows when the timezone cannot be resolved |
topbar-version.test.tsx | The top bar's version badge. Guards the rule that it shows the real version or nothing: a missing field, a non-ok response, a rejected fetch and an empty string each render nothing, because a wrong version in a bug report is worse than none. Also guards that it is polled, not read once — it must pick up a new version after an upgrade and drop the old one while the API is down, which is the whole point of a badge used to confirm an upgrade landed |
Routes and helpers
| File | Covers |
|---|---|
update-check-lib.test.ts | lib/updateCheck.ts, the update check's decision layer. Only a newer stable release is offered: a prerelease is recognised by its tag (v2.1.0-rc.1, v2.1.0-beta) even when GitHub does not flag it, and by GitHub's flag; drafts, tags that are not vX.Y.Z and malformed entries are ignored. The newest is chosen by version, not list order, because GitHub lists by creation date and a later patch to an older line would come first. SemVer precedence, numeric rather than string ordering and prerelease identifiers included, so an install on 2.1.0-rc.1 is offered 2.1.0; the same or an older version, and develop, are offered nothing. isRepo refuses anything but a bare lowercase owner/name, since it is pasted into a URL. The cache: GitHub's release list is asked at most once per 2 hours, concurrent callers share one request, and a failure (a 403 rate limit, a network error, a body that is not a list) keeps the last good list and is retried after 15 minutes rather than on every request. The request is unauthenticated and carries a User-Agent, which GitHub requires |
update-check-route.test.ts | GET /api/update-check. No session: 401, and neither the API nor GitHub is called. The API call carries the token and bypasses the Next.js Data Cache. develop, an API that sends no github_repo (one older than this route) and a malformed repo never call GitHub; an unreachable or refusing API, or an unreachable GitHub, answer "no update" rather than an error. The installed version is re-read on every request while GitHub's list stays cached, so the banner clears as soon as an upgrade lands |
update-banner.test.tsx | UpdateBanner. It names both versions and links the release and the upgrade guide in a new tab with noopener. It renders nothing for no update, a 401 or a failed request, and a link that is not https://, and never polls on /login. ✕ hides that version for this and later page loads, a newer version brings it back, and storage that throws still lets it show and dismiss for the page. Polled every 10 minutes |
proxy-route.test.ts | proxy/[...path] — the route every dashboard API call goes through, including query-string forwarding The real lock of the login: without a valid session it answers 401 and never calls Flask; every upstream call carries the API token, PATCH included; and a 401 from Flask becomes a 502 naming the fix, because past the session check it can only mean Flask refused the dashboard's own token — passed on as a 401 it would bounce the browser to /login forever. Same-site forgery: a signed-in POST or DELETE whose Sec-Fetch-Site is same-site, cross-site or none is a 403, and a urlencoded, multipart or plain-text body — even an empty one, since a form always sends a Content-Type — or a body with no type is a 415; in each case Flask is never called. same-origin, a missing header, and any GET pass. The browser's own JSON Content-Type is forwarded with its parameters, and a bodiless POST is forwarded with no body and no type, which is the shape of the dashboard's own Retry and Delete buttons. The SameSite=Strict cookie does not stop another port or a sibling subdomain, so these are what do |
stream-route.test.ts | stream/[...path] — video and voice-preview streaming, against a real temporary directory tree, with only the session mocked: the defects it guards are filesystem behaviour (a symlink followed by open, a FIFO blocking it), which a mocked fs cannot show. Without a session it answers 401 before realpath or open is called. Containment: a .. escape, a symlinked leaf pointing outside the root — even at an .mp4, so the extension check alone cannot pass it — and a file reached through a symlinked directory are all 404, with none of the target's bytes in the body; a symlink that stays inside the root still serves. Removing the realpath containment check fails the two outside-link cases. A directory (even one named dir.mp4), a FIFO named .mp4 (which must answer rather than hang) and any extension but .mp4/.mp3 are 404. The race after the check: a directory on the way swapped for a symlink to outside the root between realpath and open — done inside a wrapped open() so the race is deterministic — is a 404 without the target's bytes, because the opened descriptor's /proc/self/fd path must equal the path that was checked (O_NOFOLLOW guards only the leaf). Removing that check fails the case that fakes the descriptor's path; an unreadable /proc fails closed. The same swap is run again with process.platform set to darwin, so the /proc check is skipped (and readlink never called) and the portable device-and-inode comparison against an lstat taken before the open is the only thing refusing it; removing that comparison serves the outside file there. Without the swap the fallback still serves. File handles: a request already aborted before the stream existed, a cancelled body and a mid-stream abort each close the handle (a 16 MiB file, so it cannot close by simply running out); a refused descriptor and a 304 close it too. Content: the whole file is a byte-exact 200 with video/mp4 or audio/mpeg, nosniff, Accept-Ranges and Cache-Control: private, no-cache with an ETag (behind a login, so no shared cache may keep it; and a re-render overwrites the file at the same URL, so the browser must revalidate rather than replay the old one); bytes=a-b, a-, the suffix -n and an end past the file are byte-exact 206s with the right Content-Range and Content-Length; an inverted, past-the-end or malformed range is a 416 with bytes */<size>; a multi-range request is the whole file. The body is a ReadableStream, never one buffer. Revalidation: a matching If-None-Match (exact, weak W/, in a list, or *) is a bodiless 304, even with a Range; a file rewritten at the same path gets a new tag and its new bytes, including a same-size file renamed into place and one rewritten in place (same inode), each with its old mtime put back — the old tag must get a 200 with a new tag and the new bytes, and an old If-Range the whole file, not a 206 spliced onto old ranges. A size-and-mtime tag fails both; dropping ctime fails the in-place one (the renamed file's ctime also moves, so the inode is not pinned on its own); a Range with a matching If-Range is a 206, and with a stale tag, a weak one or a date it is the whole file as a 200 |
session.test.ts | lib/session.ts and lib/sessionCookie.ts. A session it signs verifies; no cookie, garbage, another key's signature, an expired token and alg: none do not. Rotating session_secret signs every browser out — that is how --set-password does it — and a missing secret fails closed. Sessions last seven days, fixed. Both secrets are read per call, so a rotation needs no restart. The cookie is HttpOnly and SameSite=Strict everywhere, and Secure only when the browser came over HTTPS (directly or per X-Forwarded-Proto), since a Secure cookie would make plain-http access on a LAN impossible. safeNext keeps the post-login redirect same-site: //host, /\\host and absolute URLs fall back to / |
auth-routes.test.ts | The login and logout routes, on the real next/server. The password goes to Flask with the API token and only a 200 sets a cookie; a wrong password passes through as 401 with no cookie; the backoff passes through as 429 with its Retry-After; a refused dashboard token (Flask's unauthorized, as distinct from wrong password) is a 502; an empty or non-string password never reaches Flask. Logout clears the cookie. Both refuse a same-site or cross-site request with 403 (login sets no cookie, logout leaves it alone) and a non-JSON body with 415 — an empty urlencoded form included — before anything else; same-origin, a missing Sec-Fetch-Site and logout's own bodiless POST pass |
middleware.test.ts | middleware.ts. A page load with no session cookie is redirected to /login, keeping its path and query in next (a bare /login for the root); with the cookie it passes. Presence only, by design — the signature is checked by the proxy and stream routes. The matcher covers pages and never the API routes, /stream, Next's assets, static files or /login itself, so a fetch() or a <video> gets a 401 rather than an HTML login page |
video-modal-utils.test.ts | The two /stream URL builders. videoStreamUrl builds a render's URL from the video's own project and id, each URL-encoded as one segment, under default_proj when there is no project (where the worker renders it); it never reads video_path. videoToStreamUrl rewrites the /output_videos/… path a voice preview comes back with, and leaves any other path unchanged |
visual-styles.test.ts | illustratedAvailable and styleOptions: the Illustrated style reads as available from the masked openai_api_key ("" means unset, so the key itself is never needed), counts as available when settings have not loaded, and when unavailable only that one option is disabled |
voices-lib.test.ts | The rules both voice pickers share, in lib/voices.ts: approve-topic carries a voice only when one was picked, the shown voice is the pick, else the video's own, else the install default, and a voice with no roster entry is detected so the picker can list it rather than show a different one |
agents-lib.test.ts | The agent-picker rules ActionPane and VideoModal share, in lib/agents.ts. agentOptions offers only live agents, and shows a video's own uninstalled or unknown agent disabled rather than hiding it. Picking the current agent, or an unpinned video's default, is not a change. agentPicksPayload sends only what was touched — an untouched picker sends nothing at all at SCRIPT_READY, VIDEO_READY and a revision at TOPIC_PROPOSED, loaded or not — and a changed agent never carries over a model from the old agent's list. Only topicApprovalPayload pins a model, and with no agent list it sends a blank one, never a guess, because an approval nobody touched must not submit a value the page guessed |
video-status.test.ts | The delete helper — the deletable status set matched exactly against the API's own, an already-archived video refused at any status, the request sent through the proxy, and a conflict rethrown with the API's reason. The set living in two processes is why an exact match is asserted rather than a subset |
datetime.test.ts | formatDate, formatDateShort, formatDateTime, formatClock, and parsing of legacy rows stored without a Z suffix Also the scheduling helpers: formatScheduled names the zone, wallClock reads the date in the given zone (already tomorrow in Kolkata) and never renders midnight as 24, and addDays crosses month and year ends. |
palette-contrast.test.ts | The secondary text tokens in app/globals.css. It parses the stylesheet and computes real WCAG contrast ratios, and asserts that --dim and --mute each clear AA (4.5:1) on --bg, --panel and --panel-2. Both once shipped well below that, at 10–11px, where contrast matters most. It also pins the ordering ink > ink-2 > dim > mute, so secondary text cannot be brightened until it competes with primary text. Finally, every --muted-foreground declaration must be var(--dim): a hardcoded copy of the old value is how the shadcn token drifted before |
What these encode
Timestamps are UTC in the database, host-local on display. updated_at is the FIFO ordering key
for the worker's atomic claim, and local time is non-monotonic across DST; mixed Z/offset rows also
sort wrong lexicographically. datetime.test.ts covers the legacy Z-less rows explicitly, because
those predate the convention and still exist.
The dashboard gets its timezone over HTTP, not from /etc/localtime plus an env_file. A second
source of truth could disagree, and an env_file would leak every API key into the Next.js process
for the sake of one variable. timezone-provider.test.tsx covers each way that fetch can fail.
The proxy route must forward the query string. A filter like ?status=PUBLISHED has to reach
Flask; dropping it silently returns everything, which looks like a UI bug and is not one.
Reject and revise are different actions. A flat reject is terminal and its comment is optional; a
revision requires a comment and routes to REVISION_REQUESTED. video-modal.test.tsx keeps them
distinct because the UI presents them side by side.
Frozen choices stay visible. After the topic checkpoint the script profile and agent model are no longer editable, but they must still be shown — a control that vanishes reads as data loss.
The Discord webhook URL is masked like an API key. It is the credential — anyone holding it
can post to that channel — so settings-page.test.tsx pins that its input is a password field that
unmasks only while focused, and that it renders on the Notifications tab rather than API Keys, where
it sits with the three triggers it configures.