mcp/tests
Covers mcp/server.py — the MCP server the agent talks to, and the helper modules beside it
(mcp/text_audit.py).
python -m pytest mcp/tests
Needs the MCP package dependencies — fastmcp, httpx, pillow from
mcp/requirements.txt. Several files import the server module directly, so a missing dependency
fails at collection rather than in a single test.
conftest.py provides the shared fixtures, including through_mcp, which calls a tool over a real
in-memory FastMCP client rather than as a plain function. Use it whenever a test is about what the
middleware sees or logs: only that path wraps the return value the way production does. There is no __init__.py in this directory, which is why
it can be collected alongside onboard/tests but not alongside scripts/tests.
Agent read paths
The agent cannot read the filesystem — every path column in the database is container-absolute and unreachable from the agent sandbox. These tools are the only way it sees its own inputs.
| File | Guards |
|---|---|
test_get_scene_config.py | get_scene_config — the only way to read a video's current scene-config.json. Added once the incremental revision work found the revision path was write-only |
test_read_scene_file.py | read_scene_file — the read half of the scene TSX pair. write_scene_file existed from the start; nothing ever read a scene back |
test_get_timeline.py | get_timeline — the only correct way to read timeline.json |
test_read_image.py | read_image — the only tool returning non-text content. Pins that reading never writes the coordinate grid back to disk: the file under mounted/jobs/ is the real render input, so annotating in place would bake a grid into the finished video. A 251-character name is read (a character asset's .png is a 255-byte file name) and a 252-character one, which fits no extension, is refused |
Generation
| File | Guards |
|---|---|
test_generate_image.py | generate_image — OpenAI only, with the model from image_model (the default needs no stored row), the three aspect ratios sent as their mapped sizes, and the job-directory write. A missing OpenAI key, a ratio only Gemini took, and a stored model outside the catalog (a Gemini model, gpt-image-2) are each refused before any request — the last is the backstop if migration 0016 is ever bypassed, since this server reads the row raw. Also its usage recording: every 200 is recorded against the video with OpenAI's token split into text and image tokens, including a 200 that carries no image, which was still billed; an error status records nothing. Every failure branch (an error status, a 200 with no image, a request that never got a response) must log as one error record naming the provider, model, status and body, while the value returned to the agent stays byte-identical. A 200 whose JSON or image data is malformed is logged as HTTP 200, never as "no response": the reply arrived and was billed. An empty prompt, refused before any call, is a warning with no provider fields. A 403 from a key whose OpenAI project lacks the model becomes an error naming the project and the model (OpenAI grants model access per project, and its model list shows models a project still cannot call). An image name with no room for the longest extension (.webp) in a file name is refused before the provider is called — the extension is known only after the billed reply — while a 250-character name is written |
test_generate_voice.py | Segment-derived TTS text, model and pronunciation-mode wiring, and the segment/word alignment that produces timeline.json. Voice spend is recorded as ElevenLabs plan credits read from the character-cost header — never a local character count, which strips markup ElevenLabs bills and ignores the Flash/Turbo half rate — and only a response without the header falls back to that count, marked estimated. Credits are recorded for every 200 before the body is used: a 200 with no audio used to raise out of the tool before the cost line ran, leaving paid spend unrecorded. A rejected call, a non-200, and a concurrent second call all record nothing. It also pins which models get <phoneme> tags — exactly eleven_flash_v2 and eleven_turbo_v2, the two measured to apply them — because the 2.5 models silently drop a tagged word. A segmented script with a <phoneme> tag on any other model is refused before spend rather than stripped: its voice's model can change after approval, and a stripped tag voices the word as spelled on a paid request. eleven_english_v1 must stay out of the catalog (ElevenLabs no longer has it), v3 and v4 must stay out of the break-tag set, and a bad alphabet or an over-long <break> in a script stored before those checks existed must still be refused before any spend. On eleven_v3 an <ipa> tag must reach ElevenLabs as "/…/" (v3's own syntax) while timeline.words keeps the authored word, and it must be refused before any spend when the voice's model has changed away from v3 since the script was written — on the legacy flat-script path too, which must refuse rather than strip the tag and charge for a voicing that ignores it. That path has no update_video in front of it, so it validates the tag syntax itself: a stray </ipa> or a malformed tag is refused before any request, and the check runs before <respell> substitution, which would otherwise swallow an <ipa> nested inside a <respell> unchecked An <ipa> tag must beat the lexicon for its word, and <respell> plus the lexicon must still work on v3. On eleven_v4 an <ipa> tag and a [long pause] reach ElevenLabs as v3's syntax, and the word after the pause starts after the tag — v4's alignment gives the tag's own characters the silence's duration, unlike v3 — while a <break/> there is refused before any spend. An ElevenLabs error status, a timeout and an unusable 200 each log as one error record naming the voice's model. So does a billed 200 whose alignment then fails the break-marker count or the segment lockstep: that is a paid failure, not a refusal. Its logged body leaves out the reply, which is mostly base64 audio |
test_removed_tools.py | The GIF tool stays removed. It ran agent-authored Python inside the container that mounts the credential database, and was deleted rather than sandboxed, so this pins that write_gif_script is absent from the tool list an agent sees, that nothing under mcp/ imports the old GIF toolkit, numpy or imageio, and that requirements.txt no longer pulls them in while keeping pillow, which read_image and generate_character still need |
Data model
| File | Guards |
|---|---|
test_script_segments.py | The videos.script_segments migration and the update_video / get_video support built on it, including the markup update_video refuses at authoring time. That includes two values ElevenLabs accepts without complaint and gets wrong: a <phoneme> alphabet other than cmu-arpabet/ipa (the word vanishes from the audio) and a <break> time that is not seconds in (0, 3s]. The attribute must be the real one, exactly once: a prefixed name (data-alphabet), a time="…" inside another attribute's quoted value, or a repeated attribute is refused, because a search for name= anywhere in the tag used to be fooled by all three. It also covers the <ipa> tag: it is refused on every model except eleven_v3 and eleven_v4, because the others read the IPA aloud; a tag that is unclosed, has no ph, puts spaces or slashes in ph, wraps more than one word, or leaves a stray or malformed </ipa closer is refused, because each tag must stay one token for the caption lockstep; and the derived script keeps the wrapped word, so no IPA reaches the captions |
test_voice_models.py | Per-voice models on the MCP side. The agent learns a video's model and pronunciation_mode from get_video, per video — there is no install-wide value to read, and get_pipeline_status must not carry one, because a stale install-wide mode would script a v3 video's pauses as <break/> tags. update_video checks a script against the video's own voice, refusing <phoneme> for a voice that would strip it, and pins the voice it resolved to onto a video that had none — or was passed an explicit voice_id="", which means the install default and must be recorded as it — so a later change of the install default cannot swap the model under a written script; it never overwrites a voice the video already has. A roster entry whose model_id is not a string (an unhashable list or dict) empties the roster like any other corrupt value instead of raising. A voice change alone is refused when the stored script cannot be spoken by it — including <phoneme> tags after the video's own voice was moved off a phoneme model — and re-setting the video's own voice — the negative control — never is. generate_voice sends the voice's model and refuses before spend when that voice's model changed under the script. A project's default_voice_id seeds every strategy, an explicit voice wins, a default that left the roster seeds nothing, and upsert_project stays partial: omitting the field keeps it, and re-sending a stored value that has left the roster is not refused. The cross-model revision path: at REVISION_REQUESTED a v3 voice alone is refused over a <break/> script, while the same voice together with a [pause] script in one update_video call goes through and pins it. A voice change at TOPIC_PROPOSED is not refused over an old script, and the same row at SCRIPT_READY still is. A voice written under that waiver is bound to the status: a test moves the video to SCRIPT_READY mid-call, and the write must fail and leave the old voice. get_pipeline_status's roster carries each voice's pronunciation_mode, pinned equal to what get_video reports for a video on that voice, while the stored roster keeps its shape |
test_script_profiles.py | The script-profile tools — corpus-derived style stored as markdown, built once by a cron agent session and immutable afterwards. Most of it now guards the profile schema, which exists because a profile is distilled from other people's transcripts and then fed into every later scripting prompt: exactly the create skill's seven sections in order (the fixture is pinned to the skill's text so the two cannot drift), the character cap, and the refusal of links, registered tool names (the list is read from the FastMCP registry and checked against the source's @mcp.tool count, so a new tool cannot be silently missing) and fenced code. Links are tested in every form a reader could follow — scheme-relative //host, a bare host.tld/path, a host with a port, javascript:/data:/file: — and tool names in disguise: prefixed, suffixed, re-separated, and split by an invisible character or written in fullwidth letters, since both checks read an NFKC-normalised copy with invisible characters removed. A second group covers spellings a model decodes although the plain form never appears, each refused on write and withheld on read: HTML character references (numeric, hex, named, and without the semicolon) hiding a tool name or a link, raw HTML tags and comments, a heading underlined with === or --- or nested in a blockquote or list item, a code fence inside a blockquote or list or inline, a backslash-escaped host, a numeric reference long enough (4,301 digits) that decoding it would raise rather than refuse, a scheme written straight onto its target with no // (mailto:, tel:, magnet:?, a made-up scheme), a bare email address (plain, with a +tag and several domain labels, with an escaped @, and with a domain GFM still links although it has a .-), which GFM renders as a mailto: link, Markdown's own link syntax with a relative destination (an inline link, an image, a reference definition at a line start, in a list or quote, with a label over two lines, or with an escaped ] or a trailing escaped backslash in its label, used as a full or collapsed reference) and a <mailto:…> autolink. Inline code exempts a link shape only where a reader also sees code, so each of these renders a live link and is refused: the same [claim](payoff) with no backticks, a span opened on one line and closed on the next, one opened in a heading's line, an escaped backtick, a fullwidth backtick, a span split by a table cell's ` |
test_publish_metadata.py | The publish title and description update_video requires with every script write, flat script= included. The Publish dialog only reads them, so an optional pair would be one the agent eventually forgets, leaving the dialog on the bare topic with nothing to say it fell back. Also pins the length caps (YouTube's 100-character title; a description that keeps the combined caption under Instagram's 2200) and that the refusal tells the agent to resend the stored pair on a pronunciation-only fix — or to write one when the stored pair is blank, since a video scripted before the pair existed would otherwise resend blanks, be refused again, and never get re-voiced |
test_video_title.py | videos.title (short display name) and its length cap, kept distinct from topic (the full descriptive brief) |
test_generate_character.py | generate_character — the transparent-cut-out path for the illustrated register. Pins what was probed, not read off a docs page: every offered size is divisible by 16 (the real constraint, which is why the canvas's own 1080 width cannot be requested), input_fidelity is never sent (every OpenAI catalog model rejects it outright), the model comes from the character_model setting and one that cannot draw a transparent background (gpt-image-2) is refused before any request, a 403 from a project without access to the model becomes an error naming the project and the model, the four hand-kept copies of the OpenAI catalog (and the probe script's) agree, the image_model default matches SETTINGS_SPEC's, and every catalog model has a built-in price, a reference routes to the edits endpoint, a missing OpenAI key refuses rather than silently producing an opaque image, and the real pixel dimensions are reported so a panel can be sized to the picture instead of cropping it. Every call is recorded against the video as one usage row — a regenerated pose overwrites the file but was paid for. An OpenAI refusal logs as one error record naming the provider and body, and a malformed 200 logs as HTTP 200. A character name and a reference reserve room for .png only: 251 characters is accepted for both (a 255-byte file name) and 252 is refused before any request — the .webp reservation generate_image needs once stranded 251-character characters |
test_agents.py | Installed agents as the MCP server sees them, including that get_pipeline_status's agents entries carry each agent's effort — the submit-new-job command tells the agent to mark that level as the default when offering the five, which an instruction written against a field the payload omits could not do: create_video pinning a video to one, update_video's agent_model membership check against that video's own agent, and the TOPIC_PROPOSED freeze on both agent_model and agent_effort. Pins that the freeze is checked before membership, so a late edit reads as frozen rather than as a model problem, and that agent_effort needs no installed agent at all — its five levels are fixed by the CLI, so there is no per-agent list to consult and a video with no agent must not lose the human's pick |
test_visual_style.py | The per-video visual register: the TOPIC_PROPOSED freeze on visual_style, the write-once resolved_visual_style (the only thing making a revision round reuse the register, with nothing on the revision path aware of it), the project default applying to curriculum videos alone, and write_scene_config's canvas rules — a contradicting canvas rejected, an absent one filled in rather than refused. Also pins that upsert_project is a partial update: an omitted field is left alone on an existing row, "" still clears one, and a new row still lands on the creation defaults. Its own slash-command callers are partial, so a full replace let an unrelated command blank a domain's description, style or seed flag. And that illustrated is refused — on update_video and as a new project default — while no OpenAI key is stored, with the environment fallback counting as stored. Before, the key was checked only by generate_character, after voicing, on a video whose style was already frozen. Re-sending a default that is already stored is deliberately not refused, or every later edit to that domain would fail once the key is removed |
test_project_pause.py | Domain pause, which used to be prose only. create_video refuses a paused domain for every sourced strategy and accepts manual, because a human request outranks a pause. The refusal names the domain, tells the agent to skip the candidate, and never mentions the manual exemption: the agent supplies strategy, so an error advertising it would teach the agent to relabel a candidate past the pause. The check reads status alone, so a stray paused non-seed still refuses. Also pins that only a curriculum seed can be paused through upsert_project or set_project_status, that clearing the seed flag on a paused domain reactivates it rather than stranding it, and that a partial upsert leaves a paused seed paused. Two further pins: a pause that commits between create_video's check and its INSERT still refuses, because the INSERT re-checks it (the early read holds no transaction). mine_youtube_topics refuses a paused domain before any paid Gemini call. The paused-domain refusal logs as a warning, not as a success and not as an incident |
test_topic_cap.py | A domain's whole topic history and the cap that bounds it. list_project_topics includes archived and rejected topics: list_videos hides archived ones, and deduplicating with it re-proposed deleted topics, which Auto Pilot then approved and paid for. It lists newest first, at most topic_cap rows, only the named domain, with each brief clipped at a word with "…" and an over-length legacy title clipped too. A domain at its cap is expired: create_video refuses curriculum, trending and mining there with a message naming the count and cap, saying "skip" and never mentioning the manual exemption, and accepts manual. Raising the cap re-opens the domain, and a pause still reads as a pause. A topic committed between the early count and the INSERT is still refused, because the INSERT re-counts (the early read holds no transaction). Mining refuses an expired domain before any Gemini call, asks only for the room left under the cap, and ends the run at the first refusal once another writer fills the domain; both tests count the Gemini calls, since asking after the domain closed is spend with nothing to file. upsert_project defaults the cap to 100, keeps it on a partial update, and refuses anything that is not a whole number from 25 to 750, True and 100.0 included. list_projects and set_project_status carry topic_count and expired |
test_project_description.py | A domain's description through upsert_project. A multi-line description is stored verbatim; 2000 characters is accepted (counted in code points) and 2001 refused, on a new domain and as a change, with nothing stored. The limit is checked only when the description changes: re-sending an over-limit description already stored is accepted (the negative control), an omitted description is never checked and keeps the stored one, and "" still clears it |
test_default_script_profile.py | A domain's default script profile on the MCP side. create_video seeds it onto curriculum videos only, as with the default visual style, and seeds nothing when the profile has been deleted or is not READY, since the approving PATCH would refuse it. upsert_project stays a partial update: an omitted default is kept, "" clears it, a change to an unusable profile is refused, and re-sending a stored default that was deleted since is accepted, so it never blocks an unrelated edit |
test_pipeline_status.py | Status-value handling — the SCRIPTING removal and its migration, and FAILED as an ordinary unrestricted value. Also that each mining call is recorded in the usage ledger before its reply is parsed, with no video attached — a reply that fails to parse is skipped but was still billed. A mining URL that Gemini refuses logs once, on the call's own error record with outcome: partial and the URL. It is not logged again per URL, while a reply that fails to parse keeps its own warning. Also that get_pipeline_status() returns user_preferences verbatim, since an interactive session has no other route to them, and illustrated_available — a boolean, never the key — which the scene-config skill reads before choosing generated character art. Mining is left out of active_strategies — in get_pipeline_status and get_research_strategies — while no Gemini key is stored, mining_available says so without ever carrying the key, adding a key brings it back without rewriting the stored row, and set_research_strategies refuses mining with no key and stores nothing. A list saved with no key keeps a stored mining and adds none that was not stored: the agent only sees the filtered list, so writing it back verbatim would drop mining for good. get_video reports the same flag, and the skill's text is checked to read it from there and never from get_pipeline_status: a scheduled session is warned off that tool for finding work, and with the check behind it, illustrated videos came out with no generated characters. The agent cannot set SCHEDULED (a video there needs a post postforme is holding, which no tool can create — set by hand it would only bounce to FAILED), and every copy of the status list agrees: this server's, the Flask API's and the dashboard's VideoStatus, read as source text. A stored roster over MAX_VOICES reads as empty, and one exactly at the cap reads in full. |
test_mining_urls.py | What mining may hand Gemini, and how much one run may spend. A URL becomes Gemini's file_uri on a paid call, so one bad URL refuses the whole run with zero Gemini calls — file://, a metadata or loopback address, a look-alike host, userinfo, a short id — counted on the fake client, never inferred. Gemini gets the canonical watch?v= form, and two spellings of one video are asked once. A run makes at most as many calls as topics it may file even when every URL comes back empty or every call fails, and the next run carries on from the next URL. A second run while one is in flight is refused and makes no call; the lock is released after a refused run and after one that raised. The guards from before are unchanged: no Gemini key is still an error with no call, and an empty list still points at get_mining_sources. set_mining_sources stores the canonical, deduplicated list, refuses a list with any bad entry naming each one and leaving the stored list as it was, and caps it at 50. Mutation-checked: dropping the URL check, the call bound or the lock each fails this file |
test_archived_videos.py | Archived videos are invisible to the agent: list_videos and get_pipeline_status's counts leave them out, since their files are gone and those counts feed the same decisions the poller makes from the Flask copies of these queries. One is still readable by id. Listing filters cannot cover the stale-id window — the poller handed the agent a video that a human deleted before the agent acted — so set_video_status and update_video refuse an archived row with video_archived and change nothing. That includes a move to VOICING or RENDER_QUEUED, which would spend on, or render, files that no longer exist. A live video stays writable, as the negative control |
test_pronunciation_rules.py | upsert_pronunciation_lexicon / remove_pronunciation_lexicon — the install-wide {term, respell} lexicon that replaced ElevenLabs dictionaries. Also guards the form a respelling is written in (see below), and that the skill's pointer to ElevenLabs' docs keeps its guards: syntax only, not fetched for every script, and the skill wins any conflict. Without them the docs' 3s break and their contradictory model lists would override decisions the skill exists to hold |
test_api_keys.py | Keys are read from the settings table per call. Before this they were module-level constants captured at import, so a key rotated on the dashboard needed a restart |
Scene resolution and validation
| File | Guards |
|---|---|
test_scene_resolution.py | The generated/ vs installed/ resolution surface — list_scene_files, validate_job |
The
validate_jobfixtures here, intest_scene_subfolders.pyand intest_validate_job_segments.pycopy each job file toJOBS_DIR/<video_id>/after inserting the row, becausevalidate_jobfinds the files from the id rather than the stored path columns. A file the test means to be missing is left out, so the missing-file cases still mean what they say. |test_scene_subfolders.py| Per-video namespacing:generated/{video_id}/{scene}.tsxinstead of one shared pool. Two videos naming a scene the same thing used to overwrite each other, weeks apart, between sessions that never overlapped | |test_validate_job_images.py|validate_job's image-reference checks. An unresolvablesrcis not a blank frame — Remotion's<Img>retries and then fails the render. The hint names onlygenerate_image: pointing an agent at a tool that no longer exists sends it hunting for one | |test_validate_job_segments.py| Segment integrity — thatscript_segmentsare well-formed and thattimeline.json's segments line up with them | |test_job_paths.py| Ids that become paths, at the MCP server. Every tool that builds a path from avideo_idrefuses one that is not a single path component (../x, an absolute path,a/b, a backslash,.,.., 256 bytes, a trailing newline, a NUL, a non-string) before anything else, and accepts a legacy id filed before the creation rule (a.b,Spaced Name, 129 characters — the 128 cap is for new ids only): such a video's timeline is readable, its scene lands in its own directory, and the composedsrcvalidate_jobchecks is accepted. Asrcwhose video half is..,., empty or holds a backslash or control character is refused. Withjobs/vid_alinked tojobs/vid_bandgenerated/vid_alinked togenerated/vid_b— links that stay inside their directory, so containment alone passes them — every path tool givenvid_a(read_image,generate_image,generate_character,get_timeline,get_scene_config,write_scene_config,write_scene_file,read_scene_filein both forms,delete_scene_file) is refused, makes no network call, returns none of B's content and leaves the whole tree byte-identical;validate_jobreports a scene and an image reached through a link. The same tools still work on the real tree, on a video with no directory yet and on a legacy flat scene. Routing one tool aroundtubematic_core.jobfilesfails its case — the row lookup most of them also make was the only guard before, anddelete_scene_filemade none, so an absolutevideo_idreplaced the scenes directory. Thedelete_scene_filecases leave a marker file outsidegenerated/and check it survives, including through a symlinked video directory; a video scene and a legacy flat scene are still deletable.update_videono longer declares the four path columns, and a call over a real client passing one is refused with nothing applied.validate_jobreports a planted path's file missing rather than reading it, and refuses a symlinked job file; an image reference cannot probe outsideimages/.upsert_projectrefuses a new malformed id and still edits a legacynode.jsdomain.write_scene_filerefuses a 252-character scene name, whose.tsxfile name would be 256 bytes, with an error and nothing written, and writes and reads back a 251-character one | |test_text_audit.py| The deterministic pre-render text-layout audit. This is the only layer pytest can reach — the browser half (TextLayoutProbe.tsx) and its Remotion driver have no JS test runner, which is why every threshold lives inmcp/text_audit.pyand the probe decides nothing |
Logging
| File | Guards |
|---|---|
test_tool_call_logging.py | The FastMCP middleware that writes one record per tool call. Argument values are dropped and only video_id/profile_id pass through, by allowlist. A tool that returns {"error": …} must not log like a success: that is how a depleted Gemini balance once left nothing in any log. The level follows the cause: error when a provider call failed (the call site reports it through _provider_failed), warning for a refusal with no provider call behind it, error with outcome: partial when the tool succeeded around a failed provider call, and info otherwise. It must be exactly one record per call, so a failure is never logged at the call site and again by the middleware. A provider failure's details must be in the record's message, not only its extra fields: the dashboard's Logs page renders the message alone. Reading the returned error is tested against real ToolResult objects from a throwaway FastMCP server, because FastMCP wraps the tool's dict before the middleware sees it, and a guessed shape would silently read every failure as a success. The reader must return nothing, never raise, on any other shape. Credentials are redacted in the record itself, and before the text is cut to length, not after. A key straddling the limit would otherwise survive as a fragment the stored value no longer matches. They are redacted there, not only by the JSONL formatter, because the stdout sink behind docker logs has no redacting formatter. This covers both a stored key and one supplied only through the environment |
test_log_retention.py | server.py::_log_retention_days, which configures logging before mcp.run(), so anything escaping it stops the server before it can answer even /health. A database that does not exist yet (a fresh install, or a container started before onboarding seeded it), one with no settings table, no row, a value int() rejects, and a value outside the bounds all fall back to the default instead of raising — the earlier form guarded only the empty string. A zero falls back too, since it would prune the file being written. This module cannot import pipeline.py (they run from different directories in the image), so a cross-copy test reads that file's source and asserts the bounds and the SETTINGS_SPEC default still match: the "keep in sync" comment alone never held them together |
Notes
A respelling must be written as one plain word, and the guidance is pinned so it stays that way.
test_pronunciation_rules.py asserts against the real strings an agent reads — both whitespace
rejection messages, the upsert_pronunciation_lexicon docstring, and the script-writing skill's
Pronunciation section on disk — that none of them recommends a hyphenated respelling, and that the
skill never demonstrates a tag the server itself rejects. The reason is not style: ElevenLabs does
not speak a hyphenated respelling correctly, and an unhonoured respelling returns from AI review as
a mispronunciation must-fix, so each round costs a paid re-voice. Guidance that steered an agent
to the hyphen therefore billed the user on a loop.
Two things about these tests are deliberate. They assert on the old advice phrase rather than the
word "hyphen", because the replacement wording has to name the hyphenated form in order to warn
against it. And the skill check looks only at the as="..." values demonstrated for sounds —
spelling an acronym out letter by letter (S-Y-N) is a different intent and is still correct. Same
spirit as test_ai_review.py::test_mispronunciation_bar_is_not_lowered.
Concurrency
| File | Guards |
|---|---|
test_scene_claim.py | The owner-keyed, TTL-refreshed in-process claim guarding generate_image, write_scene_file, write_scene_config and submit_render |
test_submit_render.py | submit_render as a pure DB write — no network call, an atomic guarded VOICE_READY → RENDER_QUEUED claim |
test_watermark.py | The "Made with tubematic.ai" watermark, as source guards — the component has no test runner to execute it. Pins the property the feature exists for: the agent cannot move or remove the mark. The composition draws it once, with no props, after the captions and outside the scene list (a scene entry would be dissolved at every scene boundary); the only switch is the render prop the worker sets from the watermark_enabled setting, never a scene-config.json key; and the test for that prop is !== false, so the audit and Studio preview, which send nothing, show what a render shows. It pins the maximum z-index too, since a positioned scene element with any positive z-index would otherwise paint over a later sibling. Also pins that the image is a local staticFile() and not a network fetch, and that the baked file is byte-identical to the plate-backed smoke source artwork in logos/ — a plate-less variant lost its text on light backgrounds in real renders |
test_subtitles.py | The burned-in captions, as far as Python can reach them — the config block write_scene_config accepts. Pins the two things that are decisions rather than plumbing: absent means on (the only reason the default reaches a video whose scene config predates the feature, so opting out has to be an explicit enabled: false), and the 1–7 word ceiling, rejected rather than clamped so an impossible request is visible instead of silently altered. Also pins, as source guards, the three decisions in the caption component that pytest cannot reach behaviourally: that the default font is not a webfont, that a caption's hold is capped so it cannot linger through a deliberate pause while still combining with the next caption's start, and that each webfont is loaded at one weight and subset list with Oswald at 700 — unifying the four families on one weight raises, because Oswald publishes no 800 |
test_write_scene_config.py | write_scene_config — the agent's only correct write path for scene-config.json. One test pins that it writes exactly where every reader derives the file (tubematic_core.jobfiles); test_generate_voice.py has the same agreement test for audio.mp3 and timeline.json, plus a symlinked audio.mp3, and a job directory linked to another video's, refused before any ElevenLabs call |
test_submit_render.pyandtest_write_scene_config.pybeing unmodified is the standing proof that the scene claim is claim-on-first-use rather than claim-required: an unclaimed video behaves exactly as it did before the feature existed. Do not "tidy" them into taking a claim.
Container health
| File | Guards |
|---|---|
test_mcp_auth.py | The bearer token on /mcp, on the real ASGI app the entry point serves. A missing, wrong, non-Bearer or extended token gets 401; the right one gets through; /health stays open for the compose healthcheck. A 401 must not advertise OAuth — a bare WWW-Authenticate: Bearer and no /.well-known/oauth-* routes — because a client that finds OAuth discovery starts a browser login an unattended agent run can never finish. Importing the module needs no secret (tests and vqa/stage_script.py import it); only the entry point reads the token, and it does so before mcp.run, so a missing file stops the server rather than starting it open |
test_health_route.py | GET /health, the readiness probe of the tubematic-mcp container, which docker-compose.yml's healthcheck curls. The probe it replaced POSTed an empty body to /mcp and accepted any response, so it could not tell a live server from an erroring one. Registration is not the same claim as serving: the tests build the real ASGI app with the same path="/mcp" the entry point uses and assert that /health answers at the server root and /mcp/health does not, because a FastMCP change that nested custom routes under path would leave the container never healthy and abort onboarding's renderer step — otherwise found only in a live session. The body matches the Flask API's GET /health, and the handler never touches the database: a probe that runs every few seconds reports only that the process accepts connections |