Skip to main content
Version: Latest (v2.0.0)

renderer/api/tests

Covers the Flask pipeline API, the render worker, the Gemini review client, and the host-timezone helper. One source tree, two containers — tubematic-api runs Flask plus the MCP server, tubematic-renderer runs the worker from the same image with a different command:.

python -m pytest renderer/api/tests

Must run separately from scripts/tests. Neither parent has an __init__.py, so pytest names both tests/ directories the same top-level package and the second to be collected fails to import.


FileGuards
test_pipeline.pyThe Flask blueprint — every route, SETTINGS_SPEC coercion and validation, secret masking at the two /settings boundaries, the user_preferences length cap and NUL rejection (the prompt travels as one command-line argument, so an unbounded value or an embedded NUL would fail every agent run), research run accounting, and the workable-video query. POST /research/mining-sources stores the list canonical and deduplicated, refuses the whole list naming every entry that is not a YouTube video URL (leaving the stored list unchanged), and refuses more than 50 videos; POST /research/mine is gone (mining runs only through the MCP tool, whose guards it lacked). The poller's no-progress record: POST /videos/<id>/agent-outcome counts a session that left the video at the status it started at, resets on any change of status (whoever made it), flags the video stuck with exactly one comment at the cap, counts a repeated session_key only once (so a successor replaying a dead supervisor's report can never flag a video stuck early), and never touches updated_at, which is both the poller's queue order and the stalled-VOICING clock. A stuck video drops out of /videos/workable, and either Retry agent or a human status change releases it, while a PATCH carrying no status does not. The two supervisor settings carry their defaults and are refused outside their bounds. image_model defaults to gpt-image-2.5-flare and accepts only the OpenAI catalog, refusing the Gemini models and gpt-image-2; image_provider is an unknown key, and SETTINGS_SPEC holds 40 keys. character_model saves independently of image_model and refuses any model that cannot draw a transparent background (gpt-image-2 included). AI review defaults to off. /pipeline/status reports github_repo as tubematic_core.version.GITHUB_REPO, which the dashboard's update check reads instead of spelling the repo itself. Mining is left out of GET /research/strategies and /pipeline/status while no Gemini key is stored — the environment fallback counts as stored, as for every credential — with mining_available reporting it and never the key; a key added later brings mining back because the stored row is never rewritten; POST /research/strategies refuses mining with no key and stores nothing, and a list saved with no key keeps a stored mining and adds none that was not stored (the Topics page posts back the filtered list it was shown, which would otherwise drop mining for good); and its refusal message is checked against the MCP server's copy as source text. Also the AGENT_EFFORTS cross-copy check, which reads all four hand-duplicated copies (two Python modules, the CLI adapter and the dashboard's TypeScript) as source text rather than importing them — the point is only that the four literals agree, which text comparison proves without running any of them. The ElevenLabs model sets live once in tubematic_core.voice_models, which this API, the MCP server and onboarding import; the dashboard's TypeScript copy gets the same source-text check, because a Settings page that disagreed would describe a model's markup wrongly. The roster takes exactly MAX_VOICES (10) and refuses one more, with otherwise valid entries so the count is the only thing refused. Every roster voice must carry a model from that set — a non-string model_id is a 400, and a stored one reads as an empty roster rather than raising, because an unhashable value would otherwise escape both as a TypeError — and the install-wide elevenlabs_model_id and the derived pronunciation_mode are unknown keys now — an open Settings tab from before the upgrade gets a 400, never a silent no-op. A voice preview uses the voice's own model, or a model named in the body so an unsaved change can be heard; a changed model is a new cache file, which is the ticket's "regenerate on the next play", and a body-less or non-JSON request still works because the dashboard proxy sends one. The largest file in the repository's test suite Usage capture on the Flask side: a generated voice preview records its ElevenLabs credits with no video and returns credits (a cache hit makes no call and records nothing), and the publish prefill is a pure read — it returns what the agent wrote with the script, falls back to the title and topic without storing that fallback, and makes no outbound call even with a Gemini key stored. PATCH /videos/<id> refuses a script without both publish fields, and a blank, non-string or over-cap value, because the prefill only reads the pair — a script written without it would open the dialog on the bare topic, and a rewrite would keep a pair describing the old script. The cost routes: a video's costs add the agent share of a shared session, not the session; the spend summary counts unpriced calls and omits the ElevenLabs pool when it cannot be read rather than failing, and its from/to range is whole host-timezone days — an event at 03:00Z belongs to the previous day in New York, to covers the whole of its day, each end is its own local midnight so a range across a DST switch is not off by an hour, and a host zone that cannot be named or loaded falls back to UTC days rather than failing — with a half, backwards, impossible or since-mixed range refused, and a range whose UTC boundaries overflow a datetime (to=9999-12-31; year 1 in Tokyo) a 400, never a 500. Presets are resolved server-side in the host zone — Today, a week from Monday (a Sunday belongs to the week before it; a week can start in the previous year), this month from the 1st — because the dashboard's zone is UTC until it loads; the price table names each model's source (null for none) and lists unpriced models; a manual price fills in that model's unpriced calls and can be reset; manual prices are validated and ElevenLabs is refused; and Sync now reports an offline failure as a result, not an exception. GET /publishing/accounts?feeds=0 lists accounts without a single feed probe, which is what keeps the Analytics page's account names from spending the postforme rate limit on every load. POST /usage/agent-session records every event against every video in the batch with its stage, and rejects an unknown operation or an empty event list A resent agent-session report records nothing twice — the poller logs a payload for resending after a timeout that may have come after the server committed — and a report without a session_id, which is what keys that idempotency, is refused.. GET /analytics's by_month is views gained per host-zone month: a difference of cumulative readings, never a sum, so a reading at 02:00 UTC on the 1st closes the previous New York month; the filters apply to it; an unreported reading is skipped so the one before it stands, rather than showing a drop of every view; a fall keeps its negative figure; the views a post had before its first reading land in that reading's month; months before any reading are left out rather than shown as 0; and it covers at most 12 months. POST /publishing/accounts/connect always sends ["posts", "feeds"] whatever the body says — postforme's own default is posts alone, which publishes and silently never reports a metric. It refuses a missing or unknown platform, and returns the 503s, before any outbound call. postforme's own message comes back on a 502, because for a platform not enabled on the postforme project it says where to enable it. An answer without an https URL is a 502, since the browser navigates a tab to it. Neither the minted URL (its state is single-use) nor the key reaches a log
test_worker.pyThe render worker — the atomic RENDER_QUEUED → RENDERING claim, file staging, the subprocess render timeout, and the AI-review and failure transitions. Also the publishing poll: settling per-target results, the one-hour deadline, orphan recovery, and leaving a publish that is still uploading alone. Scheduled publishing: a SCHEDULED video is untouched until it is due, is then watched with a fresh deadline and settles on its results, is not moved while publishing is off, and a post adopted by external_id whose time has not come waits in SCHEDULED instead of being failed at the deadline. Each AI review is recorded against the video from the usage the review returns — two review loops are two rows, which a flat per-video estimate could never show — and a review that never reached Gemini records nothing. The weekly price sync runs once per interval rather than every pass, is stamped before it runs so a crashing sync does not retry twenty seconds later, and never takes the loop down. The render props carry watermark from the watermark_enabled setting — on by default, off when the setting is off — and a scene-config.json that says otherwise does not count, because the agent writes that file. Staging derives every job file from the video's id and the output path from its project's: a row whose path columns were pointed elsewhere before that fix stages the video's own files, not the planted ones, and a row holding host paths (written by an MCP server driven from a host shell) still renders — the negative control. An empty column still means "not written". A project id that would leave the output directory (.., a/b) fails the render without staging anything, while a legacy one with a dot, or one longer than the 128 characters a new id is held to, renders. A symlinked job file fails the render, and so does a job directory linked to another video's (it stays inside the jobs directory, so only the per-component symlink check catches it); a symlink inside images/ is not staged. A legacy video id filed before the creation rule (a.b, Spaced Name, 129 characters) stages and renders, while one that would leave the jobs directory (.., ../x, an absolute path, a/b) or is too long to be a file name (256 bytes) fails without touching anything. A 252-byte id has a real job directory but no possible <id>.mp4, and fails with a comment before staging; a 251-byte one renders. The 129-character cases are there because the 128 cap once leaked into the use-time check and stranded such rows in FAILED.
test_job_paths.pyIds that become paths and the job-path columns, at the API. A new id on POST /videos or POST /projects must be a plain segment, each refusal beside the real shapes; an existing project with an odd id (node.js) stays editable, because the dashboard re-sends the id on every save. An unknown project_id on POST /videos is a 400, not the foreign-key 500 it used to be. PATCH /videos/<id> refuses each of the four path columns with nothing applied, even alongside a status change. The tampering cases each have a test: archiving a video whose video_path names another video's render deletes only its own file; a project id of .. deletes nothing outside the output directory; publish never uploads a render the row merely names; the retention backlog sizes the derived file; and the publish dialog's duration ignores a planted timeline_path and a symlinked timeline
test_postforme.pyWire-level tests for the postforme.dev client — credential scrubbing on every response, the emitted field names against a swagger-derived fixture, the rate limiter, the upload's total cap and non-chunked body, and which post wins when several carry the same video id, across every page. create_post sends scheduled_at only when given, and delete_post raises on postforme's 200 {success:false} and keeps the refusal's status, which is how the cancel route tells "already going out" (400) from "not found" (404). create_auth_url posts the platform and permissions with the key in the header only. An error body with a top-level message, the shape of a disabled platform's 404 (probed), surfaces as that sentence rather than a JSON dump, redacted like any other, and a nested error.message still wins
test_metrics.pyThe metric snapshot pass — that it costs one feed request per account rather than one per publication, which is the shape that stops the rate limit silently dropping snapshots; that entries are matched to publications by postforme's own post id, with external_post_id as a fallback and an unidentifiable entry skipped rather than guessed at; that another account holder's post on the same feed is ignored; that pagination follows the cursor, stops when the feed says so, and is bounded so a runaway cursor cannot spin inside the worker loop; that one unreachable account never blinds the others and poll never raises; and that snapshots append, keeping the whole platform payload rather than only the four columns
test_retention.pyDeletion and the retention sweep — the deletable status set (pinned, and matched against the dashboard's own copy so the two processes cannot drift), refusal for statuses mid-process or under a claim (and SCHEDULED, where postforme holds a post that will go out), a second delete as a conflict, missing files not failing the delete, a removal error leaving the row archived, other videos' files left untouched, and archived rows staying out of GET /videos unless asked for. For unpublished_retention_days it covers: an old FAILED or TOPIC_REJECTED video is archived with its status kept, a rejected topic with no files is archived cleanly, and no other status is ever reached. The two keys' passes are independent in both directions, the stamp still throttles when only the new key is on, a corrupt value falls back to off, and neither a retry nor an edit that lands between the sweep's select and the archive loses the video's files. test_a_video_that_is_not_published_is_never_swept, run with the key unset, is the pin that the default of 0 changes nothing. GET /retention/backlog is checked for counts and sizes per status, excluding archived rows, ignoring the key, and deleting nothing. It also never sizes anything outside a direct child of the jobs directory (ids ., .. and ../x), and never walks a directory in place of the mp4
test_ai_review.pyWire-level tests for ai_review.py — the resumable Files upload, the poll-until-ACTIVE loop, the request body shape, and the fail-open path. Also pins the prompt's subtitle clauses: that captions are stated to be correct by design, that a scene duplicating the narration is asked for as a suggestion, and that text carrying its own information is carved out. It pins the watermark paragraph the same way: the mark is on every video and scenes are never laid out around it, so without the statement that it is intentional (content beneath it included, and its absence too) every review returns a finding. The module is DB-free, so it returns the review's usage for the worker to record; that must happen on every path where Gemini answered 200, including a reply that failed to parse (it was still billed), and on no path where it did not. A reply that parses to a JSON array rather than an object fails open instead of escaping the never-raises contract.. The differ branch must also say that an IPA transcription between slashes is pronounced rather than read out: without it, a working <ipa> term on eleven_v3 or eleven_v4 reads as a mispronunciation, and every such finding is a paid re-voice
test_visual_style.pyThe visual register on the HTTP side: visual_style accepted, frozen with 409 past TOPIC_PROPOSED, and still writable by the approving request that carries the status change with it; the project default reaching curriculum videos only; and the review prompt's style contract. It also pins the two hand-copied copies of the register constants and helpers against mcp/server.py by executing the other copy, not by comparing text — a constants-only comparison is what gave false confidence before. It also pins the OpenAI-key gate: illustrated is refused with one shared message on PATCH, on an explicit POST /videos and as a new project default when no key is stored (settings row or environment). A default inherited from the domain is kept, not rewritten, and is caught instead by the approving PATCH, before any spend. An unchanged default re-sent by the full-replace POST /projects is not refused, because refusing it would block unrelated edits to the domain
test_manual_submit.pyPOST /videos/manual, the dashboard's New Video route. It files every video into the manual domain: created on first use as an active non-seed, with the description that keeps the research agent out, and never overwritten once someone renames it. It lands at TOPIC_APPROVED by default and at TOPIC_PROPOSED when auto-approve is off, and is unscored. The tests that matter most are the writes-nothing ones. _conn() commits on exit, so a refusal reached after the domain INSERT would leave an empty domain behind on every bad submission. Picks are type-checked on the raw value, so a false or 0 is refused rather than read as "the default". Also covered: a refused model gives back the deck card it dealt, and a picked agent is kept rather than dealt, which is the opposite of POST /videos
test_project_pause.pyThe HTTP half of domain pause. POST /videos refuses a paused domain with 409 unless strategy is manual. It is not only the dashboard's proxy target, so a refusal in MCP alone would leave a second write path open. POST /projects and /projects/<id>/status refuse a paused non-seed. It also pins the two hand-copied refusal messages against mcp/server.py by executing the other copy, because the agent can reach either path and has to read the same instruction from both. The same execution pins the shared INSERT guard and the topic-cap block that lives beside it: the bounds, the count and project-select SQL, the expiry message and the cap validation, and the description limit and its validation. It also covers a pause committing between the check and the INSERT
test_secret_key_copies.pyThe secret-typed settings keys are spelled three times: SETTINGS_SPEC here, and copies in mcp/server.py and scripts/collect_diagnostics.py, neither of which can import this module. A key missing from a copy is a credential that copy's redaction only catches by pattern, or not at all — the MCP copy had already lost discord_webhook_url. The copies are read with ast, so the test needs neither process's dependencies
test_topic_cap.pyThe HTTP half of the topic cap. POST /videos refuses an expired domain with 409 for every sourced strategy and accepts manual; POST /videos/manual files past the manual domain's cap, because the New Video page always files there. A topic committed between the check and the INSERT is still refused. Mining's behaviour at the cap is covered by the MCP suite, mining's only path. POST /projects defaults the cap to 100, stores a given one, resets an omitted one on this full replace (the dashboard re-sends it), accepts 25 and 750, refuses anything else that is not a whole number in range, and accepts a cap below the count, which expires the domain at once. GET /projects and /projects/<id>/status carry topic_count and expired
test_project_description.pyA domain's description over HTTP. POST /projects stores a multi-line description verbatim, accepts 2000 characters (2000 emoji included, since the limit counts code points as the dashboard does) and refuses 2001 on a new domain and as a change to an existing one, storing nothing. The negative control matters most: an unchanged description already over the limit, planted as one stored before the limit existed, is re-sent with a style change and must save. This route is a full replace the dashboard re-sends on every edit, so refusing it would block every edit to that domain. A non-string description is a 400 rather than reaching the INSERT; null and an absent one store ''
test_default_script_profile.pyA domain's default script profile over HTTP. POST /videos seeds it onto curriculum videos only, and never onto trending, mining or manual ones (including POST /videos/manual, which files into the manual domain). A default deleted since seeds nothing, and a later change never reaches back to videos already filed. POST /projects refuses a non-string (false, 0, [] included, checked on the raw value so none of them is read as "no default" and silently clears the stored one), unknown, deleted or not-READY profile when the default changes, and accepts a stored one deleted since. The route is a full replace that re-sends the default with every edit, so refusing it would block renaming the domain
test_video_voice_info.pyThe voice_info a single-video payload carries for the modal's read-only Voice rows. The case that matters most is a voice's model edited in Settings after voicing. The payload must still name the model the ledger says the audio was made with: reading the roster there names a model the audio was never made with. The opposite trap is a video sent back before voicing (TOPIC_PROPOSED through VOICING) that still holds an old ledger row. A revision may have switched its voice, so that row must not be paired with the next voicing's voice; it reports the current setting instead. Also covered: after a re-voice, the latest call wins, pinned with a model that differs from the roster's so a roster fallback cannot pass. A video with no ledger row, or another video's row, falls back to the current setting. A voice off the roster keeps its id with an empty name, a blank voice resolves to the install default, a PATCH response carries the field, and GET /videos never does: it would cost a ledger query per row.
test_voice_models.pyPer-voice models over HTTP. A voice change the stored script cannot use is refused with 409 at the checkpoint and changes nothing — the alternative is a paid voicing that reads IPA aloud or drops a pronunciation. The negative control is the test that matters most: approving with the video's own voice is never refused, or every approval would break. A voice whose model was edited in Settings after scripting is caught at approval, for <phoneme> tags as well as <ipa>; a legacy flat script counts only <ipa>. The video payload's voice_conflicts comes from the same function the refusal uses, so the dashboard's grey-out and the 409 cannot disagree. A project's default_voice_id seeds every video filed into it on every creation path (all strategies and the manual route), an explicit voice wins, a default that left the roster seeds nothing, and a later change to the default never reaches back. A stale stored default re-sent by the full-replace POST /projects must not block the edit. At TOPIC_PROPOSED no voice conflicts, even when a topic revision left an old script stored, because approving has it rewritten — while the identical row at SCRIPT_READY is still refused, so the waiver is the status and not the script. A voice written under that waiver is bound to the status: a test moves the video to SCRIPT_READY mid-request and the write must match no row
test_api_auth.pyThe API's bearer token and the dashboard's password login. Every route except the two health routes answers 401 without the right token, including an unknown path, so a route added later is covered by default. The check sits on the app, not the pipeline blueprint, and exempts the health routes by endpoint name, never by path prefix — both choices are mutation-checked. The login needs the token too, refuses an oversized password before hashing it, says how to set a password when none is set, and backs off install-wide after a few failures, answering 429 without checking the password while locked. The hash is never in GET /settings and POST /settings cannot write it. server.py refuses to start without the token file, so a missing secret never means an open API Only one password check runs at a time: a login arriving while another is still hashing is refused with Retry-After: 1 without hashing — without that, a burst of concurrent logins all passed the backoff before any recorded a failure, each hashing about 32 MiB. The slot is freed even when hashing raises.. The route sweep names POST /publishing/accounts/connect explicitly, since it spends a postforme request
test_hosttz.pyhosttz.host_timezone() — the timezone label shown in the dashboard
test_discord_notify.pyWire-level tests for discord_notify.py — webhook URL shapes, the tri-state save-time probe, Discord's message size limits, rate-limit retry, and that no error text can ever carry the webhook token. Also that a topic with score 0, an unscored New Video submission, is announced as "unscored" rather than "0/10", which would read as a failed score
test_entrypoint.pyrenderer/entrypoint.sh, run for real as a subprocess against temporary directories. The shared logs directory must end up in the data directory's group with group read, write and search, plus setgid. It is checked from both 0755 (the reported shape) and 0700, where write alone would be useless without search, because it has a writer that is not a container: poll.py appends to it from cron as the host user, and a root-owned logs directory silently kept every poll entry off the dashboard. A missing logs directory must not stop the container. Only the normal branch is covered; a non-root test user cannot create the root-owned directory the fallback branch handles
test_notifications.pyThe render worker's notification sweep — which videos are announced and when, topic bundling and its settle window, second arrivals after a revision round, backlog suppression while a trigger is off, and the migration backfill
test_voicing_stall.pyThe worker's time-in-status alert for VOICING, worker.py::_sweep_stalled_voicing. A video no session manages to voice — an agent that refuses generate_voice, a call that keeps erroring, a poller that stopped — otherwise sits in VOICING looking exactly like one whose agent has not run yet; the alert is what makes it visible. A video in VOICING for three poll intervals, read from the install's own poll_interval_mins (a garbage value falls back to the default), gets one comment, an ERROR log line and, with a webhook set, a Discord alert regardless of the per-checkpoint toggles. It fires once per arrival, not once per pass, and again after a fresh arrival such as a pronunciation re-voice. No other status, and no archived video, is ever flagged, and a video that moves between the select and the flag is not. Nothing fires while the pipeline is paused, or for three intervals after it resumes — a paused flag would be a false alarm and would spend the arrival's one-shot marker, silencing a real stall later. A failed Discord send keeps the comment and is not retried, or the comment would repeat every pass. updated_at is never touched, because it is what the stall is measured on and the render claim's FIFO key; the sweep never raises; and sharing notified_status with the checkpoint sweep must not make that sweep announce anything
test_autopilot_slot.pyautopilot.next_slot, the pure rule for when Auto Pilot may post next. Each half is pinned alone and together: the gap holds against a post already scheduled ahead as well as one behind (posting an hour before a human's scheduled post breaks the gap just as posting an hour after would); a full day rolls to the next local midnight, cut in the host's zone and not UTC, and correct across a daylight-saving change where the day is 23 hours long; a slot that frees one account is re-checked against the others, because one post goes to all of them; and nothing free inside the horizon gives up rather than looping
test_auto_pilot.pyAuto Pilot end to end below the dashboard: the /autopilot routes and the worker's sweep. Most of it is what Auto Pilot must not touch, since it removes every human checkpoint for the projects it covers. The routes: off and empty by default; a malformed document writes nothing at all; an account outside the Socials allowlist is refused, so Auto Pilot can never post where a human publish could not, while one already stored for that project never blocks a save (turning Auto Pilot off must always be possible); a corrupt stored row reads as no project selected. The approve pass: each checkpoint moves on with a comment and clears a stuck flag as a human approval does; an unselected project, a switched-off install, a paused pipeline and an archived video are left alone; the two approvals the dashboard would refuse (an illustrated topic with no OpenAI key, a script its voice cannot speak) are not made and are commented on once however often the worker ticks, then made once the cause is fixed; an auto-approved checkpoint is never also announced; and a failing sweep never escapes the worker loop. The publish pass: one public post to the project's accounts; a video with nowhere to post makes no postforme request at all; the gap and the per-account daily number applied, with a failed post using up neither and Auto Pilot's own queue spreading over following days; one video started per pass; an account that is off the allowlist, disconnected or set to 0 is dropped from the post, and a project left with none waits at VIDEO_APPROVED; nothing is posted with publishing disabled. A cancelled schedule is not scheduled again — without the hold the cancel could never stick. A failed upload is not retried, or commented on, every tick, and a backed-off video does not block the next one. The in-progress limit, which holds back only the paid gate (SCRIPT_READY → VOICING) and, while full, topic approvals: 0 (the default) means no limit, whatever the backlog. What the count includes is pinned case by case: every voiced, unpublished video in an Auto Pilot project, VOICING included before its voice file exists; a revision keeps its slot at SCRIPT_READY, TOPIC_PROPOSED and REVISION_REQUESTED, or it would free one for a new script and then queue behind it; never-voiced, published, failed, rejected and archived videos are out; a held VIDEO_APPROVED video is out, since nothing in the dashboard clears the hold and counting it would shut the gate for good; in a project not on Auto Pilot only a future SCHEDULED post counts (so a post a human scheduled counts), never an overdue one or an ambiguous PUBLISHING one, which do not leave the count by themselves. New scripts are voiced exactly up to the room left in one pass and never past it; a backlog over the limit voices none; topics wait only while it is full. A voiced revision passes the gate at the limit without using room, so a full set of revisions can never deadlock. At the limit, finished videos are still approved and scheduled: the old ceiling stopped the scheduler too and stranded them at VIDEO_APPROVED. With nowhere to post, nothing new is voiced (the reported defect: nothing reached SCHEDULED, so the old ceiling never bound). One video publishing lets exactly one more script through, the top-up chosen over draining to zero, and a script refused for its voice uses no room. A two-account post counts once; the limit round-trips through /autopilot, a save without it (or with the old max_scheduled from a tab opened before the upgrade) keeps it, and both /autopilot and /settings refuse a value past MAX_AUTO_PILOT_IN_FLIGHT

What these encode​

The reviewer is told which visual register it is judging. The prompt used to state no intended style at all, so every video was judged against an unstated default — which for black line art on white means animation_defect ("unclean, misplaced, or misaligned", wording written around polished vector diagrams) and the prompt's open-ended escalation clause both fire on the register itself. Under the "any finding routes to REVISION_REQUESTED" rule that is not a grading problem: each round is a paid re-voice, so a systematically mis-judged register wedges the video. The tests pin that the contract forbids the three specific false positives observed (flat art as low quality, a held pose as broken animation, a white frame as a blank frame), that it still demands a real review of legibility, safe zones and sync, and that an unknown register states no contract at all — guessing is the one unsafe direction, because a wrong contract licenses defects the video has no permission to contain.

The captions' negative control matters more than the positive one. Every video is now captioned, so a reviewer that read the captions as text duplicating the narration would return a finding on every video — and any finding routes to REVISION_REQUESTED. That is a wedged pipeline, not a missed check, which is why the prompt states outright that the captions are correct by design and why a test pins that sentence. The positive half — a scene that also writes out the narration — is deliberately a suggestion rather than a must-fix, and a third test pins the carve-out for text carrying its own information (a number, a pinned label, a chart axis), without which every diagram beat would cost a revision round.

The register's two figure clauses are pinned apart. They began as one block sent whenever a video could contain figures at all, which told an illustrated video — generated character artwork, line art excluded — that simple line-art stick figures were intended. The tests pin that an illustrated video loses the line-art clause and keeps the discrete-motion one, because the error directions are not symmetric: over-sending the line-art clause costs a missed catch, while dropping the held-pose licence costs false positives on every video in that register, and any finding is a revision round. A further test pins that line_art without figures emits nothing, so a stray flag cannot describe figures the video cannot contain.

AI review fails open, deliberately. A broken review advances the video to VIDEO_READY rather than failing the pipeline. That went unnoticed for the feature's entire lifetime once already, which is why the worker now writes a comment and videos.gemini_feedback on every outcome — pass, defects, or error. The tests pin both the fail-open transition and the always-comment behaviour; they are a pair, and removing the second makes the first invisible again.

The review at the loop cap still runs. The boundary is count > cap, not >=. With >= the very first review at cap = 1 would force acceptance, giving zero revision rounds ever. Tests pin the strict comparison, and pin that the early-return shortcut is not reintroduced — it used to ship renders carrying the previous round's stale feedback.

Two separate loop caps. max_render_retries governs render crash/timeout retries; ai_review_max_loops governs the review loop. They used to be one key, so granting a review loop of 1 silently halved the render-retry budget. test_worker.py keeps them independent.

test_ai_review.py exists because the older tests mocked too high. The AI-review tests inside test_pipeline.py mock httpx.Client wholesale, so an incorrectly shaped request body still passes. test_ai_review.py asserts at the wire level instead — it is the file that would have caught the upload bug that leaked an API key.

Secrets are masked at the route boundary only. _read_all_settings returns raw values because internal callers need them; GET /settings and the POST response both mask. Masking only the GET would leak a key on every save. Tests cover both directions.

An HTTP client's own request log is a leak path when the credential is in the URL. Every other provider here sends its key as a header, so the client's HTTP Request: <method> <url> line is harmless; a Discord webhook token is in the path, so that line prints a live credential on every call. Found by running the feature against a real stack, not by reading the code — a working token reached both docker logs and a JSONL file. Two tests pin the fix, and one pins that it did not become a blanket suppression of the client's logging, which is how the other providers get debugged.

The webhook URL is a credential, and the tests treat it as one. Anyone holding it can post to that channel. HTTP clients put the request URL in their exception text and this pipeline persists exception text into the dashboard-visible comments table, which is how a key leaked once. test_discord_notify.py asserts the token is absent from every error the module can produce, including a transport failure and an HTTP error whose body echoes the request.

Only a positive rejection may gate a settings save. The webhook probe is tri-state: a 401 or 404 refuses the save, but a timeout, a 5xx or a rate limit stores the URL anyway. Tests pin both halves — collapsing "unreachable" into "invalid" would make Settings un-saveable whenever Discord is unwell.

Composition clips rather than hoping. Discord answers 400 when an embed exceeds any size cap, and a rejected message is a notification nobody ever receives, with nothing reporting the loss. The tests run every builder against deliberately absurd input and assert every documented limit, including the 6000-character total across a message.

Five sweep behaviours are load-bearing, each of them a real bug if it regressed: the migration backfill (so an upgrade does not announce months of parked videos at once), backlog suppression while a trigger is off (so switching one on does not fire everything parked since), second arrivals (so a video returning to a checkpoint after a revision round is announced again), leaving updated_at untouched (it is the FIFO ordering key for the render claim, so notifying must not reorder the render queue), and not advancing the marker on a failed send (so the next tick retries instead of losing it).

The second-arrival test deliberately runs the whole round with no sweep in between. An earlier cut keyed the marker on the status name alone and passed a test that swept at each step, while silently dropping the second notification in the case that actually happens — a revision requested and acted on while the worker is inside a render. The no-sweep version is the one that pins the behaviour; keep it.

A publish that is still uploading must never be failed. Flask writes the publication rows at the claim, with an empty post id, and the worker treats such rows as in flight. The regression test drives the real publish route and runs a real worker pass from inside the upload, the exact moment the original bug struck: postforme has no post yet, and the old worker read that as a crashed publish and failed a video that went live seconds later. It fails against the old code and must keep doing so. The same section pins that a dead post under the video's id is not adopted before the deadline, since it can belong to an earlier attempt. It also pins that adopting a post restarts the deadline, and that expiry never fails a video whose post id was filled in at the same moment.

A publish request must always end before the worker's deadline. Past it, a late create_post would have nothing correct to attach to. test_worker.py asserts the arithmetic, which is the upload cap plus the per-operation timeouts, against _PUBLISH_TIMEOUT_SECS, so raising one timeout without the other fails a test instead of reopening the race. test_pipeline.py pins that a request fills only the rows it inserted itself.

Scheduling: the wall time is converted by the API, and a cancel must never strand a post. resolve_schedule is tested with a fixed now: a zone's offset is taken on the scheduled day, not today, so a New York time in December is UTC-5 even when the test runs in summer; a wall time the clocks skip is refused rather than silently shifted, which is what zoneinfo would otherwise do; a repeated hour takes its first occurrence, or the second once the first is too soon — "in one hour" during the first occurrence is the same wall time again, and was refused as too soon until a review caught it. Through the route, a bad schedule is a 400 that creates nothing, a failed upload clears the schedule with the claim, and a post the upload leaves within a minute of its time (SCHEDULE_POST_NOW_SECS, already passed or not) is sent as a publish-now, because postforme refuses a past scheduled_at and the route would have to read that as ambiguous. The cancel tests pin the order — postforme first, local state after: a 400 (already going out) and an ambiguous failure both leave the rows, a 404 releases only once postforme no longer lists the post, and a cancel racing the worker's due-time move to PUBLISHING still releases, since a deleted post cannot go out.

Retry publishing never posts twice. It asks postforme before acting, adopts a live post rather than re-posting, treats a post that failed everywhere as not posted, and treats a postforme outage as "unknown", never as "nothing was posted". The claim on FAILED is atomic, and a test forces two overlapping retries to show that only one acts.

The version is asserted against tubematic_core.version.version(), never a literal. Otherwise the test would need editing on every release, and — more usefully — it would stop failing if a second definition of the version were ever introduced and drifted from the real one. A sibling test pins that /stats is still a pure status→count map, which is the reason the version was put on /pipeline/status instead: callers iterate /stats expecting every value to be a count.

hosttz reads glibc's cached zone. timezone_label comes from datetime.now().astimezone(), which does not see a /etc/localtime change made after the process started. The test documents the caching behaviour rather than asserting a specific zone.