Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

688 lines
29 KiB
Python

"""U7: /v1/notebooks/{id}/artifacts generate / poll / download / list."""
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from notebooklm._app.generate import GenerationExecutionResult
from notebooklm._app.generate_retry import GenerationOutcome
from notebooklm._types.artifacts import GenerationState
from notebooklm.server._pending import PendingRegistry
from notebooklm.server.routes import artifacts as artifacts_route
from notebooklm.server.routes.artifacts import DOWNLOAD_SPECS, GENERATE_TYPES
from .fakes import FakeClient, make_artifact
def _generate_audio(authed_client: TestClient) -> str:
resp = authed_client.post("/v1/notebooks/nb-1/artifacts", json={"type": "audio"})
assert resp.status_code == 202
body = resp.json()
assert body["status"] == "pending"
return body["task_id"]
def test_generate_audio_returns_202_and_task_id(authed_client: TestClient) -> None:
task_id = _generate_audio(authed_client)
assert task_id
def test_generate_unknown_field_is_422(authed_client: TestClient) -> None:
"""#1874-A: ArtifactGenerate forbids extra keys; a typo'd option -> 422."""
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "audio", "stylee": "anime"}
)
assert resp.status_code == 422
def test_generate_valid_body_still_202(authed_client: TestClient) -> None:
"""#1874-A: a valid body with known optional fields is unaffected by extra='forbid'."""
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts",
json={"type": "audio", "instructions": "Keep it short", "language": "en"},
)
assert resp.status_code == 202
def test_poll_known_task_not_found_is_200_pending(
authed_client: TestClient, fake_client: FakeClient
) -> None:
task_id = _generate_audio(authed_client)
# Simulate the post-generate lag: poller returns NOT_FOUND for a known task.
fake_client.poll_states[("nb-1", task_id)] = GenerationState.NOT_FOUND
resp = authed_client.get(f"/v1/notebooks/nb-1/artifacts/{task_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "not_found"
def test_poll_transitions_to_completed(authed_client: TestClient, fake_client: FakeClient) -> None:
task_id = _generate_audio(authed_client)
fake_client.poll_states[("nb-1", task_id)] = GenerationState.IN_PROGRESS
assert (
authed_client.get(f"/v1/notebooks/nb-1/artifacts/{task_id}").json()["status"]
== "in_progress"
)
fake_client.poll_states[("nb-1", task_id)] = GenerationState.COMPLETED
done = authed_client.get(f"/v1/notebooks/nb-1/artifacts/{task_id}")
assert done.status_code == 200
assert done.json()["status"] == "completed"
def test_poll_removed_is_410(authed_client: TestClient, fake_client: FakeClient) -> None:
task_id = _generate_audio(authed_client)
fake_client.poll_states[("nb-1", task_id)] = GenerationState.REMOVED
resp = authed_client.get(f"/v1/notebooks/nb-1/artifacts/{task_id}")
assert resp.status_code == 410
def test_poll_failed_is_409(authed_client: TestClient, fake_client: FakeClient) -> None:
task_id = _generate_audio(authed_client)
fake_client.poll_states[("nb-1", task_id)] = GenerationState.FAILED
resp = authed_client.get(f"/v1/notebooks/nb-1/artifacts/{task_id}")
assert resp.status_code == 409
def test_poll_unknown_task_is_404(authed_client: TestClient) -> None:
resp = authed_client.get("/v1/notebooks/nb-1/artifacts/never-generated")
assert resp.status_code == 404
def test_download_completed_artifact_streams_bytes(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/download", json={"type": "audio"})
assert resp.status_code == 200
assert resp.content == fake_client.download_bytes
def test_download_not_ready_is_409(authed_client: TestClient) -> None:
# No artifacts exist → NO_ARTIFACTS → 409, not 500.
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/download", json={"type": "audio"})
assert resp.status_code == 409
def test_download_caller_path_field_is_ignored(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
# An attacker-supplied path-like field is not in the schema and is ignored.
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts/download",
json={"type": "audio", "output_path": "/etc/passwd"},
)
assert resp.status_code == 200
def test_list_artifacts(authed_client: TestClient, fake_client: FakeClient) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio", title="Pod")}
resp = authed_client.get("/v1/notebooks/nb-1/artifacts")
assert resp.status_code == 200
assert resp.json()["artifacts"][0]["title"] == "Pod"
# Default (no limit) stays unbounded, no meta block.
assert "meta" not in resp.json()
def test_list_artifacts_pagination_slices_and_adds_meta(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {
f"a{i}": make_artifact(f"a{i}", "audio", title=f"Pod{i}") for i in range(5)
}
body = authed_client.get(
"/v1/notebooks/nb-1/artifacts", params={"limit": 2, "offset": 1}
).json()
assert body["notebook_id"] == "nb-1"
assert len(body["artifacts"]) == 2
assert body["meta"] == {"total": 5, "has_more": True, "limit": 2, "offset": 1}
def test_list_artifacts_bad_limit_is_422(authed_client: TestClient) -> None:
assert authed_client.get("/v1/notebooks/nb-1/artifacts", params={"limit": 0}).status_code == 422
# --- generate: input validation (400s) --------------------------------------
def test_generate_unknown_type_is_400(authed_client: TestClient) -> None:
resp = authed_client.post("/v1/notebooks/nb-1/artifacts", json={"type": "bogus"})
assert resp.status_code == 400
def test_generate_unsupported_language_is_400(authed_client: TestClient) -> None:
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "audio", "language": "zz-bogus"}
)
assert resp.status_code == 400
def test_generate_invalid_option_choice_is_400(authed_client: TestClient) -> None:
# A provided per-kind option is validated up front (clean 400, not a raw
# KeyError deeper in generate-core).
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "audio", "audio_format": "bogus"}
)
assert resp.status_code == 400
def test_generate_explicit_valid_option_is_202(authed_client: TestClient) -> None:
# An explicit valid option flows through to the generation plan.
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "audio", "audio_format": "brief"}
)
assert resp.status_code == 202
# --- download: input validation + format axis --------------------------------
def test_download_unknown_type_is_400(authed_client: TestClient) -> None:
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/download", json={"type": "bogus"})
assert resp.status_code == 400
def test_download_output_format_on_unsupported_type_is_400(authed_client: TestClient) -> None:
# audio has no format axis, so an output_format is a clean 400.
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts/download",
json={"type": "audio", "output_format": "mp3"},
)
assert resp.status_code == 400
def test_download_with_output_format_streams(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {"d1": make_artifact("d1", "slide-deck")}
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts/download",
json={"type": "slide-deck", "output_format": "pdf"},
)
assert resp.status_code == 200
assert resp.content == fake_client.download_bytes
def test_download_unexpected_output_path_is_rejected(
authed_client: TestClient, fake_client: FakeClient, tmp_path: object
) -> None:
# If the core resolves a served path OUTSIDE the server's private temp dir,
# the route refuses to stream it (path-traversal safety guard). tmp_path is a
# distinct tree from the server's mkdtemp dir, so the guard fires.
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
fake_client.download_return_path = os.path.join(str(tmp_path), "nblm-outside-artifact.mp3")
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/download", json={"type": "audio"})
assert resp.status_code == 400
# --- helpers: _cleanup + _generation_payload ---------------------------------
def test_cleanup_unlinks_a_file(tmp_path: object) -> None:
target = os.path.join(str(tmp_path), "leftover.bin")
with open(target, "wb") as fh:
fh.write(b"x")
artifacts_route._cleanup(target)
assert not os.path.exists(target)
def test_cleanup_missing_path_is_noop(tmp_path: object) -> None:
# Already-gone path must not raise. tmp_path is unique per test, so the path
# is guaranteed absent (no risk of deleting unrelated state).
artifacts_route._cleanup(os.path.join(str(tmp_path), "nblm-does-not-exist-xyz"))
def test_generation_payload_mind_map_returns_inline() -> None:
# A mind-map renders synchronously: no task_id, the map is inlined.
result = GenerationExecutionResult(
kind="mind-map", display_name="Mind map", mind_map={"root": 1}
)
payload = artifacts_route._generation_payload("nb-1", result, PendingRegistry())
assert payload["mind_map"] == {"root": 1}
assert "task_id" not in payload
def test_generation_payload_without_outcome() -> None:
# No generation outcome and no mind map → bare {notebook_id, kind}.
result = GenerationExecutionResult(kind="audio", display_name="Audio", generation=None)
payload = artifacts_route._generation_payload("nb-1", result, PendingRegistry())
assert payload == {"notebook_id": "nb-1", "kind": "audio"}
def test_generation_payload_outcome_without_task_id_is_not_recorded() -> None:
# A falsy task_id is projected but never recorded in the pending registry.
pending = PendingRegistry()
outcome = GenerationOutcome(status="ok", artifact_type="audio", task_id="")
result = GenerationExecutionResult(kind="audio", display_name="Audio", generation=outcome)
payload = artifacts_route._generation_payload("nb-1", result, pending)
assert payload["task_id"] == ""
assert not pending.knows("nb-1", "")
def test_download_temp_dir_cleaned_on_normal_completion(
authed_client: TestClient, fake_client: FakeClient, monkeypatch: object
) -> None:
import pytest
assert isinstance(monkeypatch, pytest.MonkeyPatch)
made: list[str] = []
real_mkdtemp = tempfile.mkdtemp
def _tracking_mkdtemp(*args: object, **kwargs: object) -> str:
d = real_mkdtemp(*args, **kwargs)
made.append(d)
return d
monkeypatch.setattr(artifacts_route.tempfile, "mkdtemp", _tracking_mkdtemp)
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/download", json={"type": "audio"})
assert resp.status_code == 200
assert resp.content == fake_client.download_bytes
# After the TestClient fully consumed the streamed body, the temp dir the
# download spooled into is gone (the _CleanupFileResponse finally ran).
assert made and not os.path.exists(made[0])
async def test_cleanup_file_response_cleans_on_disconnect(tmp_path: object) -> None:
# Simulate a client disconnect mid-stream: super().__call__ raises, and the
# subclass's finally must still remove the temp dir (a BackgroundTask would be
# dropped on disconnect and leak it).
temp_dir = tempfile.mkdtemp(prefix="nblm-disconnect-", dir=str(tmp_path))
served = os.path.join(temp_dir, "artifact.mp3")
with open(served, "wb") as fh:
fh.write(b"bytes")
resp = artifacts_route._CleanupFileResponse(served, temp_dir=temp_dir)
class _Boom(Exception):
pass
async def _receive() -> dict[str, object]:
return {"type": "http.disconnect"}
async def _send(message: dict[str, object]) -> None:
raise _Boom # abort mid-stream, as a disconnect would
scope = {"type": "http", "method": "GET", "headers": []}
# Catch the SPECIFIC simulated-abort exception so an unrelated failure in the
# cleanup path is not silently swallowed — the test only passes for the abort
# it staged.
with pytest.raises(_Boom):
await resp(scope, _receive, _send)
assert not os.path.exists(temp_dir)
# --- Phase 4: studio lifecycle (delete / retry) ------------------------------
def test_delete_artifact_is_204(authed_client: TestClient, fake_client: FakeClient) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
resp = authed_client.delete("/v1/notebooks/nb-1/artifacts/a1")
assert resp.status_code == 204
assert ("nb-1", "a1") in fake_client.deleted_artifacts
assert "a1" not in fake_client.artifacts_store["nb-1"]
def test_delete_absent_artifact_is_204_idempotent(authed_client: TestClient) -> None:
# Idempotent-on-missing, like the notebook/source/note DELETE routes.
assert authed_client.delete("/v1/notebooks/nb-1/artifacts/ghost").status_code == 204
def test_retry_artifact_returns_task_id_and_status(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/a1/retry")
assert resp.status_code == 200
body = resp.json()
# Retry kicks off a task whose id equals the artifact id (mirrors MCP).
assert body["artifact_id"] == "a1"
assert body["task_id"] == "a1"
assert body["status"] == "pending"
# The kicked-off task is now pollable.
assert authed_client.get("/v1/notebooks/nb-1/artifacts/a1").json()["status"] == "pending"
def test_retry_accepts_json_content_type_without_content_length(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
def _empty_body() -> Iterator[bytes]:
if False:
yield b""
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts/a1/retry",
content=_empty_body(),
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 200
assert resp.json()["task_id"] == "a1"
def test_retry_records_task_in_pending_registry(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# retry records its task_id (like generate), so a poll that briefly races ahead
# of the artifact listing resolves to 200 pending instead of a spurious 404.
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/ghost/retry")
assert resp.status_code == 200
# Transient window: the retried task polls NOT_FOUND before it appears in listings.
fake_client.poll_states[("nb-1", "ghost")] = GenerationState.NOT_FOUND
poll = authed_client.get("/v1/notebooks/nb-1/artifacts/ghost")
assert poll.status_code == 200 # recorded on retry → 200 (would be 404 unrecorded)
assert poll.json()["status"] == "not_found"
def test_retry_refusal_propagates_as_error(
authed_client: TestClient, fake_client: FakeClient
) -> None:
from notebooklm.exceptions import RateLimitError
fake_client.retry_error = RateLimitError("slow down")
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/a1/retry")
assert resp.status_code == 429
assert resp.json()["error"]["retriable"] is True
def test_retry_plain_str_status_does_not_crash(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# GenerationStatus.status is raw-string-permissive: a plain-str status must
# NOT crash the route (``.value`` would raise AttributeError). The route
# projects it enum-or-str-safely.
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio")}
fake_client.retry_status = "pending" # a plain str, not a GenerationState enum
resp = authed_client.post("/v1/notebooks/nb-1/artifacts/a1/retry")
assert resp.status_code == 200
assert resp.json()["status"] == "pending"
# --- Phase 4: artifact rename (PATCH) ----------------------------------------
def test_rename_artifact(authed_client: TestClient, fake_client: FakeClient) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "audio", title="Old")}
resp = authed_client.patch("/v1/notebooks/nb-1/artifacts/a1", json={"title": "New"})
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "renamed"
assert body["new_title"] == "New"
assert body["is_mind_map"] is False
assert fake_client.artifacts_store["nb-1"]["a1"].title == "New"
def test_rename_artifact_missing_title_is_422(authed_client: TestClient) -> None:
assert authed_client.patch("/v1/notebooks/nb-1/artifacts/a1", json={}).status_code == 422
# --- Phase 4: kind-aware mind-map rename/delete + full-UUID case ---------------
# A canonical (lowercase) full UUID and its uppercase spelling. The backend id is
# canonically lowercase; a caller may send the uppercase form.
_MM_ID = "abcd1234-5678-4abc-def0-1234567890ab"
_MM_ID_UPPER = _MM_ID.upper()
def _seed_note_backed_mind_map(fake_client: FakeClient, mm_id: str) -> None:
"""Seed a note-backed mind map (the ``delete_artifact`` probe list) plus a
backing note with the same id, so a note-path delete is observable."""
from types import SimpleNamespace
from notebooklm._types.notes import Note
fake_client.note_backed_mind_maps["nb-1"] = [SimpleNamespace(id=mm_id)]
fake_client.notes_store["nb-1"] = {
mm_id: Note(id=mm_id, notebook_id="nb-1", title="Map", content="")
}
def _seed_interactive_mind_map(fake_client: FakeClient, mm_id: str) -> None:
"""Seed an interactive mind map into the ``rename_artifact`` probe list."""
from notebooklm._types.mind_maps import MindMap, MindMapKind
fake_client.mind_maps_store["nb-1"] = [
MindMap(id=mm_id, notebook_id="nb-1", title="Map", kind=MindMapKind.INTERACTIVE)
]
def test_rename_mind_map_is_mind_map_branch(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# A resolved mind map routes through the kind-aware mind_maps.rename, not the
# plain artifacts.rename, and is flagged is_mind_map=True.
from notebooklm._types.mind_maps import MindMapKind
_seed_interactive_mind_map(fake_client, "mm-1")
resp = authed_client.patch("/v1/notebooks/nb-1/artifacts/mm-1", json={"title": "New"})
assert resp.status_code == 200
assert resp.json()["is_mind_map"] is True
nb, mm_id, title, kind = fake_client.renamed_mind_maps[0]
assert (nb, mm_id, title) == ("nb-1", "mm-1", "New")
assert kind == MindMapKind.INTERACTIVE
# The plain artifact rename path was NOT taken.
assert fake_client.renamed_artifacts == []
def test_rename_uppercase_full_uuid_mind_map_routes_mind_map_path(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# An UPPERCASE full UUID for a note-backed/interactive mind map must be
# normalized to canonical lowercase before the case-sensitive core probe, so
# it still routes to the mind-map path (not a false plain-artifact success).
_seed_interactive_mind_map(fake_client, _MM_ID)
# URL built in a local, then passed to the HTTP PATCH helper — keeping an
# f-string out of the client call so the ADR-0007 monkeypatch guardrail does
# not false-positive an ``authed_client`` HTTP PATCH as a computed mock target.
url = f"/v1/notebooks/nb-1/artifacts/{_MM_ID_UPPER}"
resp = authed_client.patch(url, json={"title": "New"})
assert resp.status_code == 200
assert resp.json()["is_mind_map"] is True
# Renamed via the mind-map path with the canonical lowercase id.
assert fake_client.renamed_mind_maps
assert fake_client.renamed_mind_maps[0][1] == _MM_ID
assert fake_client.renamed_artifacts == []
def test_delete_note_backed_mind_map_routes_notes_delete(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# A note-backed mind map is CLEARED via notes.delete, never artifacts.delete.
_seed_note_backed_mind_map(fake_client, "mm-note")
resp = authed_client.delete("/v1/notebooks/nb-1/artifacts/mm-note")
assert resp.status_code == 204
# notes.delete removed the backing note; the artifact delete path was skipped.
assert "mm-note" not in fake_client.notes_store["nb-1"]
assert fake_client.deleted_artifacts == []
def test_delete_uppercase_full_uuid_note_backed_mind_map_routes_notes_delete(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# The full-UUID case-normalization fix: an uppercase UUID for a note-backed
# mind map still routes to the note path instead of a false artifact-delete
# "success" that leaves the map intact.
_seed_note_backed_mind_map(fake_client, _MM_ID)
resp = authed_client.delete(f"/v1/notebooks/nb-1/artifacts/{_MM_ID_UPPER}")
assert resp.status_code == 204
assert _MM_ID not in fake_client.notes_store["nb-1"]
assert fake_client.deleted_artifacts == []
# --- Phase 4: artifact prompt read -------------------------------------------
def test_get_prompt_returns_stored_prompt(
authed_client: TestClient, fake_client: FakeClient
) -> None:
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "report")}
fake_client.prompts_store[("nb-1", "a1")] = "Summarize the sources"
resp = authed_client.get("/v1/notebooks/nb-1/artifacts/a1/prompt")
assert resp.status_code == 200
assert resp.json() == {
"notebook_id": "nb-1",
"artifact_id": "a1",
"prompt": "Summarize the sources",
}
def test_get_prompt_null_is_200_not_404(authed_client: TestClient, fake_client: FakeClient) -> None:
# A known artifact that records no prompt → prompt=null at 200, not a 404.
fake_client.artifacts_store["nb-1"] = {"a1": make_artifact("a1", "mind-map")}
resp = authed_client.get("/v1/notebooks/nb-1/artifacts/a1/prompt")
assert resp.status_code == 200
assert resp.json()["prompt"] is None
def test_get_prompt_unknown_artifact_is_404(authed_client: TestClient) -> None:
assert authed_client.get("/v1/notebooks/nb-1/artifacts/ghost/prompt").status_code == 404
# --- Phase 4: generate option hygiene ----------------------------------------
def test_generate_wrong_kind_option_is_400(authed_client: TestClient) -> None:
# ``orientation`` belongs to infographic, not audio → rejected (not silent no-op).
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "audio", "orientation": "landscape"}
)
assert resp.status_code == 400
def test_generate_optionless_kind_rejects_any_option(authed_client: TestClient) -> None:
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "data-table", "difficulty": "easy"}
)
assert resp.status_code == 400
def test_generate_bad_new_option_value_is_400(authed_client: TestClient) -> None:
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts", json={"type": "video", "style": "not-a-style"}
)
assert resp.status_code == 400
def test_generate_forwards_instructions(authed_client: TestClient, fake_client: FakeClient) -> None:
# Both ``description`` and ``instructions`` are set in raw_args; for audio the
# generate core forwards the instruction text to the client generator.
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts",
json={"type": "audio", "instructions": "focus on chapter 3"},
)
assert resp.status_code == 202
assert fake_client.last_generate_kwargs is not None
assert fake_client.last_generate_kwargs.get("instructions") == "focus on chapter 3"
def test_generate_blank_instructions_treated_as_absent(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# Whitespace-only instructions must not reach the client as a blank prompt slot
# (keeps the default request shape byte-identical).
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts",
json={"type": "audio", "instructions": " "},
)
assert resp.status_code == 202
assert fake_client.last_generate_kwargs is not None
assert fake_client.last_generate_kwargs.get("instructions") is None
def test_generate_mind_map_forwards_instructions(
authed_client: TestClient, fake_client: FakeClient
) -> None:
# mind-map reads raw_args["instructions"] (not ``description``): the default
# interactive kind routes through client.mind_maps.generate, and the
# instruction text must reach it.
resp = authed_client.post(
"/v1/notebooks/nb-1/artifacts",
json={"type": "mind-map", "instructions": "group by theme"},
)
assert resp.status_code == 202
assert fake_client.last_mind_map_generate is not None
assert fake_client.last_mind_map_generate["instructions"] == "group by theme"
def test_kind_options_match_core_maps() -> None:
"""The REST per-kind option table is pinned to the neutral core's choice maps.
Duplicated (the server layer must not import the core privates) but pinned so
a core-map change surfaces here — the same guardrail the MCP ``_KIND_OPTIONS``
table carries.
"""
import notebooklm._app.generate_plans as gp
from notebooklm.server.routes.artifacts import _KIND_OPTIONS
assert _KIND_OPTIONS["audio"]["audio_format"] == tuple(gp._AUDIO_FORMAT_MAP)
assert _KIND_OPTIONS["audio"]["audio_length"] == tuple(gp._AUDIO_LENGTH_MAP)
assert _KIND_OPTIONS["video"]["video_format"] == tuple(gp._VIDEO_FORMAT_MAP)
assert _KIND_OPTIONS["video"]["style"] == tuple(gp._VIDEO_STYLE_MAP)
assert _KIND_OPTIONS["slide-deck"]["deck_format"] == tuple(gp._SLIDE_FORMAT_MAP)
assert _KIND_OPTIONS["slide-deck"]["deck_length"] == tuple(gp._SLIDE_LENGTH_MAP)
assert _KIND_OPTIONS["quiz"]["quantity"] == tuple(gp._QUIZ_QUANTITY_MAP)
assert _KIND_OPTIONS["quiz"]["difficulty"] == tuple(gp._QUIZ_DIFFICULTY_MAP)
assert _KIND_OPTIONS["flashcards"]["quantity"] == tuple(gp._QUIZ_QUANTITY_MAP)
assert _KIND_OPTIONS["flashcards"]["difficulty"] == tuple(gp._QUIZ_DIFFICULTY_MAP)
assert _KIND_OPTIONS["infographic"]["orientation"] == tuple(gp._INFOGRAPHIC_ORIENTATION_MAP)
assert _KIND_OPTIONS["infographic"]["detail"] == tuple(gp._INFOGRAPHIC_DETAIL_MAP)
assert _KIND_OPTIONS["infographic"]["style"] == tuple(gp._INFOGRAPHIC_STYLE_MAP)
assert _KIND_OPTIONS["report"]["report_format"] == tuple(gp._REPORT_FORMAT_MAP)
def test_kind_options_exact_mcp_parity() -> None:
"""The REST and MCP ``_KIND_OPTIONS`` tables must be byte-for-byte identical.
Both are duplicated from the neutral core (the CLI/MCP/server boundary forbids
importing the core privates); pinning them EQUAL to each other guarantees the
two agent surfaces validate the SAME per-kind options with the SAME choices.
"""
import pytest
pytest.importorskip("fastmcp") # MCP tools need the ``mcp`` extra
from notebooklm.mcp.tools.studio import _KIND_OPTIONS as mcp_options
from notebooklm.server.routes.artifacts import _KIND_OPTIONS as rest_options
assert rest_options == mcp_options
def test_kind_options_cover_every_generate_type() -> None:
"""Every ``GENERATE_TYPES`` kind has a ``_KIND_OPTIONS`` entry, and the
per-kind-only keys (``map_kind``, ``style_prompt``) + optionless kinds are
exactly as expected — so a new generate kind can't silently ship without its
option contract."""
from notebooklm.server.routes.artifacts import _KIND_OPTIONS
# Exhaustive over the generate surface (no missing / extra kinds).
assert set(_KIND_OPTIONS) == set(GENERATE_TYPES)
# ``map_kind`` is validated at the boundary ONLY (no core map) — it must exist
# on mind-map and nowhere else.
assert "map_kind" in _KIND_OPTIONS["mind-map"]
assert all("map_kind" not in opts for k, opts in _KIND_OPTIONS.items() if k != "mind-map")
# ``style_prompt`` (free text, choices None) belongs to video only.
assert _KIND_OPTIONS["video"]["style_prompt"] is None
assert all("style_prompt" not in opts for k, opts in _KIND_OPTIONS.items() if k != "video")
# Optionless kinds carry an explicit empty map (so a stray option is rejected).
assert _KIND_OPTIONS["cinematic-video"] == {}
assert _KIND_OPTIONS["data-table"] == {}
def test_download_spec_exhaustiveness() -> None:
"""Every studio download kind the client supports has a server spec.
The generate types that produce a downloadable artifact must each have a
matching ``DownloadTypeSpec`` (cinematic-video downloads as video; mind-map
has both generate + download).
"""
downloadable_generate = set(GENERATE_TYPES) - {"cinematic-video"}
assert downloadable_generate <= set(DOWNLOAD_SPECS)
# Every download spec is also a real ArtifactType-backed row.
for name, spec in DOWNLOAD_SPECS.items():
assert spec.name == name
assert spec.download_attr.startswith("download_")