diff --git a/app/config.py b/app/config.py index 9def09e..3491373 100644 --- a/app/config.py +++ b/app/config.py @@ -16,6 +16,7 @@ class Settings: spreadsheet_id: str drive_folder_id: str base_url: str + gcs_bucket: str = "" model_names: list[str] = field(default_factory=list) max_output_tokens: int = 1024 rate_limit_per_minute: int = 10 @@ -28,6 +29,7 @@ def from_env(cls) -> "Settings": spreadsheet_id=os.getenv("SKIPTA_SPREADSHEET_ID", ""), drive_folder_id=os.getenv("SKIPTA_DRIVE_FOLDER_ID", ""), base_url=os.getenv("SKIPTA_BASE_URL", "http://localhost:8000"), + gcs_bucket=os.getenv("SKIPTA_GCS_BUCKET", ""), model_names=[m.strip() for m in os.getenv("SKIPTA_MODEL_NAMES", DEFAULT_MODELS).split(",") if m.strip()], max_output_tokens=int(os.getenv("MAX_OUTPUT_TOKENS", "1024")), rate_limit_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "10")), diff --git a/app/drive.py b/app/drive.py deleted file mode 100644 index 88700fd..0000000 --- a/app/drive.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Drive integration: locate (or create) the customer's subfolder under the shared Skipta folder, upload signed PDFs idempotently.""" -import io - -from googleapiclient.http import MediaIoBaseUpload - -FOLDER_MIME = "application/vnd.google-apps.folder" - - -def _escape(value: str) -> str: - return value.replace("\\", "\\\\").replace("'", "\\'") - - -def find_customer_folder(drive, root_folder_id: str, customer_name: str): - query = ( - f"'{_escape(root_folder_id)}' in parents and mimeType = '{FOLDER_MIME}' " - f"and name = '{_escape(customer_name)}' and trashed = false" - ) - result = drive.files().list(q=query, fields="files(id, name)", pageSize=5).execute() - files = result.get("files", []) - return files[0]["id"] if files else None - - -def ensure_customer_folder(drive, root_folder_id: str, customer_name: str) -> str: - existing = find_customer_folder(drive, root_folder_id, customer_name) - if existing: - return existing - created = drive.files().create( - body={"name": customer_name, "mimeType": FOLDER_MIME, "parents": [root_folder_id]}, fields="id" - ).execute() - return created["id"] - - -def find_file_in_folder(drive, folder_id: str, filename: str): - query = f"'{_escape(folder_id)}' in parents and name = '{_escape(filename)}' and trashed = false" - result = drive.files().list(q=query, fields="files(id, webViewLink)", pageSize=1).execute() - files = result.get("files", []) - return files[0].get("webViewLink") if files else None - - -def upload_pdf(drive, folder_id: str, filename: str, pdf_bytes: bytes) -> str: - media = MediaIoBaseUpload(io.BytesIO(pdf_bytes), mimetype="application/pdf") - created = drive.files().create( - body={"name": filename, "parents": [folder_id]}, media_body=media, fields="webViewLink" - ).execute() - return created["webViewLink"] diff --git a/app/gcs.py b/app/gcs.py new file mode 100644 index 0000000..b4849c0 --- /dev/null +++ b/app/gcs.py @@ -0,0 +1,20 @@ +"""Signed-PDF archive in GCS, keyed by customer prefix. Consumer-account service accounts have zero Drive storage quota (uploads are rejected outright), so the flattened PDFs live in a public-read bucket instead; the Drive folder remains the human-side SoW archive.""" + + +def blob_name(customer_name: str, filename: str) -> str: + return f"{customer_name.replace(' ', '_')}/{filename}" + + +def public_url(bucket: str, name: str) -> str: + return f"https://storage.googleapis.com/{bucket}/{name}" + + +def find_pdf(storage_client, bucket: str, name: str): + if storage_client.bucket(bucket).blob(name).exists(): + return public_url(bucket, name) + return None + + +def upload_pdf(storage_client, bucket: str, name: str, pdf_bytes: bytes) -> str: + storage_client.bucket(bucket).blob(name).upload_from_string(pdf_bytes, content_type="application/pdf") + return public_url(bucket, name) diff --git a/app/google_clients.py b/app/google_clients.py index f7cefd0..c14e0c1 100644 --- a/app/google_clients.py +++ b/app/google_clients.py @@ -18,8 +18,10 @@ def build_sheets(creds): return build("sheets", "v4", credentials=creds, cache_discovery=False) -def build_drive(creds): - return build("drive", "v3", credentials=creds, cache_discovery=False) +def build_storage(creds, project_id: str): + from google.cloud import storage + + return storage.Client(project=project_id, credentials=creds) def make_model_factory(project_id: str, region: str): diff --git a/app/main.py b/app/main.py index 66d82c7..501fcc6 100644 --- a/app/main.py +++ b/app/main.py @@ -14,12 +14,11 @@ from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address -from app import amendments, pricing -from app import drive as drive_mod +from app import amendments, gcs, pricing from app.amendments import AmendmentRecord from app.config import Settings from app.extraction import ExtractionError, extract_amendment -from app.google_clients import build_drive, build_sheets, get_credentials, make_model_factory, read_values +from app.google_clients import build_sheets, build_storage, get_credentials, make_model_factory, read_values from app.pdf import render_amendment_html, render_pdf logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") @@ -50,10 +49,11 @@ def get_sheets(): return _clients["sheets"] -def get_drive(): - if "drive" not in _clients: - _clients["drive"] = build_drive(get_credentials()) - return _clients["drive"] +def get_storage(): + if "storage" not in _clients: + settings = get_settings() + _clients["storage"] = build_storage(get_credentials(), settings.project_id) + return _clients["storage"] def get_extract(): @@ -164,7 +164,7 @@ def sign_amendment( body: SignRequest, settings: Settings = Depends(get_settings), sheets=Depends(get_sheets), - drive=Depends(get_drive), + storage=Depends(get_storage), ): found = amendments.find_amendment(sheets, settings.spreadsheet_id, amendment_id) if found is None: @@ -184,13 +184,13 @@ def sign_amendment( created_ts = amendment_id.rsplit("_", 1)[-1] filename = f"{record.customer_name.replace(' ', '_')}_Amendment_{created_ts}.pdf" # deterministic per amendment — a retry reuses the same name + name = gcs.blob_name(record.customer_name, filename) try: - folder_id = drive_mod.ensure_customer_folder(drive, settings.drive_folder_id, record.customer_name) - pdf_url = drive_mod.find_file_in_folder(drive, folder_id, filename) or drive_mod.upload_pdf(drive, folder_id, filename, pdf_bytes) + pdf_url = gcs.find_pdf(storage, settings.gcs_bucket, name) or gcs.upload_pdf(storage, settings.gcs_bucket, name, pdf_bytes) amendments.mark_signed(sheets, settings.spreadsheet_id, row, pdf_url, record.signed_at) except HTTPException: raise - except Exception as exc: # Drive/Sheets upstream failure — surface honestly + except Exception as exc: # GCS/Sheets upstream failure — surface honestly logger.error("sign flow failed for %s: %s", amendment_id, exc) raise HTTPException(status_code=502, detail=f"Google API failure: {exc}") from exc logger.info("amendment %s signed → %s", amendment_id, pdf_url) diff --git a/docs/plans/2026-07-01-skipta-field-amendments-design.md b/docs/plans/2026-07-01-skipta-field-amendments-design.md index 6058c9b..ccd4f8d 100644 --- a/docs/plans/2026-07-01-skipta-field-amendments-design.md +++ b/docs/plans/2026-07-01-skipta-field-amendments-design.md @@ -29,10 +29,9 @@ One FastAPI (Python 3.11+) service, single Deployment in the `skipta` namespace: ├── extraction.py — Gemini structured output (Vertex AI, Workload Identity) ├── pricing.py — Sheets Panels/Breakers lookup + totals ├── amendments.py — append Amendments row (status=draft) - ├── drive.py — locate customer SoW subfolder └── returns {amendment_id, signing_url} [Any phone] --GET /amendments/{id}--> server-rendered signing page (Jinja2 + signature_pad) - --POST /api/v1/amendments/{id}/sign--> pdf.py (WeasyPrint) → Drive upload → row status=signed + --POST /api/v1/amendments/{id}/sign--> pdf.py (WeasyPrint) → gcs.py upload → row status=signed ``` ### Modules @@ -44,7 +43,7 @@ One FastAPI (Python 3.11+) service, single Deployment in the `skipta` namespace: | `app/extraction.py` | Gemini structured output → `AmendmentPayload` | Vertex AI | | `app/pricing.py` | match payload items against `Panels`/`Breakers` tabs, compute totals | Sheets | | `app/amendments.py` | amendment state rows in the `Amendments` tab (draft→signed) | Sheets | -| `app/drive.py` | customer SoW folder search, PDF upload | Drive | +| `app/gcs.py` | signed-PDF archive: customer-prefixed blob names, idempotent find-or-upload, public URLs | GCS | | `app/pdf.py` | render amendment HTML (with signatures) → flattened PDF | WeasyPrint | Google clients are constructed in one place and injected, so tests swap in fakes without patching. @@ -56,7 +55,7 @@ Google clients are constructed in one place and injected, so tests swap in fakes | `GET /` | Mobile intake form: customer name + voice-text area (phone dictation), posts to the API | | `POST /api/v1/amendments` | Extract → price → persist draft row → return `{amendment_id, signing_url}` | | `GET /amendments/{id}` | Server-rendered signing page: itemized parts, totals, two signature_pad canvases | -| `POST /api/v1/amendments/{id}/sign` | Accept both Base64 signatures, render PDF, upload to Drive, flip row to `signed` | +| `POST /api/v1/amendments/{id}/sign` | Accept both Base64 signatures, render PDF, upload to the GCS archive, flip row to `signed` | | `GET /healthz` | Probe target (ting convention) | `amendment_id` is `amend__` per the MVP naming convention. A small hand-rolled mobile-first stylesheet and `signature_pad.umd.js` are vendored under `app/static/` — no CDN dependency on a job site with weak signal, and no Node/Tailwind toolchain in the build (a deliberate simplification of the MVP's TailwindCSS suggestion). @@ -75,7 +74,7 @@ One spreadsheet, three tabs. `Panels` and `Breakers` follow the MVP schema exact - `Breakers`: `breaker_id`, `amps`, `poles`, `description`, `unit_cost` - `Amendments` (state machine, one row per amendment): `amendment_id`, `created_at`, `customer_name`, `voice_text`, `extracted_json`, `line_items_json`, `total`, `status` (`draft` | `signed`), `pdf_drive_url`, `signed_at` -The signing page re-reads its row on every GET, so replicas and pod restarts are invisible. Drive layout: a `Skipta/` folder shared with the service account, one subfolder per customer holding their SoW doc; signed PDFs upload into that subfolder as `[Customer_Name]_Amendment_[Timestamp].pdf`. +The signing page re-reads its row on every GET, so replicas and pod restarts are invisible. Signed PDFs live in the public-read GCS bucket `skipta-amendments-teralivekubernetes` under a per-customer prefix (`Smith/Smith_Amendment_.pdf` — the deterministic name doubles as the retry-idempotency key), and the `pdf_drive_url` column carries the public object URL. The Drive `Skipta/` folder (shared with the service account) is the human-side archive: one subfolder per customer holding their SoW doc. ## Google auth — no tokens, no keys @@ -83,13 +82,14 @@ One service account, `skipta-gsa@teralivekubernetes.iam.gserviceaccount.com`: - **Vertex AI:** `roles/aiplatform.user` on project `teralivekubernetes` (mirror of `um-vertex-ai-gsa`). - **Drive/Sheets:** no IAM role — the human shares the `Skipta/` folder and the spreadsheet with the GSA email as Editor. `drive.googleapis.com` and `sheets.googleapis.com` get enabled on the project. +- **GCS:** `roles/storage.objectUser` on the `skipta-amendments-teralivekubernetes` bucket (create + read the PDF archive); `allUsers` hold `objectViewer` so amendment links open like share links. Consumer-account service accounts have zero Drive storage quota — they cannot own Drive files or folders — which is why the PDF archive is a bucket rather than the Drive folder. - **On GKE:** KSA `skipta-sa` in the `skipta` namespace, annotated `iam.gke.io/gcp-service-account=skipta-gsa@…`, with the `roles/iam.workloadIdentityUser` binding for `teralivekubernetes.svc.id.goog[skipta/skipta-sa]`. The workload pool is already enabled on `ttf-cluster`. - **Locally:** `gcloud auth application-default login --impersonate-service-account=skipta-gsa@…` (requires `roles/iam.serviceAccountTokenCreator` on the GSA for the human) — dev runs as the same identity the pod uses. - **In code:** `google.auth.default(scopes=[drive, spreadsheets, cloud-platform])`; the GKE Workload Identity metadata server honors requested scopes. Escape hatch if scope-narrowing misbehaves: self-impersonated credentials via the IAM Credentials API (`impersonated_credentials` targeting the same GSA with explicit Drive/Sheets scopes). -Config that reaches the pod is identifiers only (spreadsheet ID, Drive folder ID, project, region, model list) — a ConfigMap, no Secret. +Config that reaches the pod is identifiers only (spreadsheet ID, Drive folder ID, GCS bucket name, project, region, model list) — a ConfigMap, no Secret. -**Documented caveat:** PDFs uploaded by the GSA are owned by it and count against the service account's own ~15 GB Drive quota. Acceptable for a demo; the promote-to-real path is a Workspace Shared Drive. +**Documented caveat:** the PDF bucket is public-read for demo-tier link sharing, so amendment PDFs are world-readable to anyone holding the URL — fine for sample customers. The promote-to-real path is a private bucket with signed URLs (or a Workspace Shared Drive, which restores Drive-native storage). ## Kubernetes deployment diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml index 1878062..a42b6a5 100644 --- a/k8s/base/configmap.yaml +++ b/k8s/base/configmap.yaml @@ -8,4 +8,5 @@ data: gcp_region: "us-east1" spreadsheet_id: "11cKYWrc4TdfdfD5ko2BkAzttCy7-q2U79LmNeQ2Rn1w" drive_folder_id: "1kUxdx8qgOzG7-s_1zRI3FlWWG70XQGHd" + gcs_bucket: "skipta-amendments-teralivekubernetes" base_url: "https://skipta.cmdbee.org" diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml index e81f36a..5050ac8 100644 --- a/k8s/base/deployment.yaml +++ b/k8s/base/deployment.yaml @@ -27,6 +27,8 @@ spec: valueFrom: {configMapKeyRef: {name: skipta-config, key: spreadsheet_id}} - name: SKIPTA_DRIVE_FOLDER_ID valueFrom: {configMapKeyRef: {name: skipta-config, key: drive_folder_id}} + - name: SKIPTA_GCS_BUCKET + valueFrom: {configMapKeyRef: {name: skipta-config, key: gcs_bucket}} - name: SKIPTA_BASE_URL valueFrom: {configMapKeyRef: {name: skipta-config, key: base_url}} readinessProbe: diff --git a/requirements.txt b/requirements.txt index 7b713cf..4ba0d1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ uvicorn[standard]>=0.30,<1.0 # <1.160: vertexai.generative_models is past its announced removal date; 1.159 verified to still ship it google-cloud-aiplatform>=1.60,<1.160 google-api-python-client>=2.100,<3.0 +google-cloud-storage>=2.14,<4.0 google-auth>=2.30,<3.0 jinja2>=3.1,<4.0 weasyprint>=62,<70 diff --git a/tests/conftest.py b/tests/conftest.py index 8d2e0fd..5bddb0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,10 @@ from fastapi.testclient import TestClient from app.extraction import AmendmentPayload -from app.main import app, get_drive, get_extract, get_settings, get_sheets +from app.main import app, get_extract, get_settings, get_sheets, get_storage from app.config import Settings from tests.test_amendments import FakeSheets -from tests.test_drive import FakeDrive +from tests.test_gcs import FakeStorageClient PANEL_ROWS = [["P-200A-01", "200", "200A Main Lug Panel 30-Space", "245.00"]] BREAKER_ROWS = [["B-20A-1P", "20", "1", "20A Single-Pole Type BR", "7.50"]] @@ -30,7 +30,7 @@ def _routed_get(self, spreadsheetId, range): @pytest.fixture def fakes(): - return {"sheets": RoutedFakeSheets([]), "drive": FakeDrive([{"id": "folder-smith", "name": "Smith"}])} + return {"sheets": RoutedFakeSheets([]), "storage": FakeStorageClient()} @pytest.fixture @@ -40,10 +40,10 @@ def client(fakes): ) app.dependency_overrides[get_settings] = lambda: Settings( project_id="p", region="r", spreadsheet_id="sid", drive_folder_id="root", base_url="http://testserver", - model_names=["fake"], max_output_tokens=64, rate_limit_per_minute=1000, + gcs_bucket="test-bucket", model_names=["fake"], max_output_tokens=64, rate_limit_per_minute=1000, ) app.dependency_overrides[get_sheets] = lambda: fakes["sheets"] - app.dependency_overrides[get_drive] = lambda: fakes["drive"] + app.dependency_overrides[get_storage] = lambda: fakes["storage"] app.dependency_overrides[get_extract] = lambda: (lambda voice_text, settings: payload) # The limiter's limit-lambda calls get_settings() directly, outside Depends resolution, # so dependency_overrides can't reach it — the suite would share one real 10/min budget. diff --git a/tests/test_drive.py b/tests/test_drive.py deleted file mode 100644 index 2254d03..0000000 --- a/tests/test_drive.py +++ /dev/null @@ -1,68 +0,0 @@ -from app.drive import FOLDER_MIME, ensure_customer_folder, find_customer_folder, find_file_in_folder, upload_pdf - - -class FakeFiles: - def __init__(self, listing): - self.listing = listing - self.created = None - - def list(self, q, fields, pageSize): - self.q = q - self._result = {"files": self.listing} - return self - - def create(self, body, media_body=None, fields=""): - self.created = {"body": body, "media": media_body} - self._result = {"id": "new-id", "webViewLink": "https://drive.google.com/file/d/abc/view"} - return self - - def execute(self): - return self._result - - -class FakeDrive: - def __init__(self, listing=()): - self._files = FakeFiles(list(listing)) - - def files(self): - return self._files - - -def test_find_folder_builds_query_and_returns_id(): - drive = FakeDrive([{"id": "folder-smith", "name": "Smith"}]) - assert find_customer_folder(drive, "root123", "Smith") == "folder-smith" - assert "'root123' in parents" in drive.files().q - assert "mimeType = 'application/vnd.google-apps.folder'" in drive.files().q - assert "name = 'Smith'" in drive.files().q # exact match — 'contains' would let Smith claim Smithson's folder - - -def test_find_folder_escapes_quotes(): - drive = FakeDrive([]) - assert find_customer_folder(drive, "root123", "O'Brien") is None - assert "O\\'Brien" in drive.files().q - - -def test_upload_pdf_returns_link_and_targets_folder(): - drive = FakeDrive() - link = upload_pdf(drive, "folder-smith", "Smith_Amendment_20260701120000.pdf", b"%PDF-1.7 fake") - assert link.startswith("https://drive.google.com/") - assert drive.files().created["body"]["parents"] == ["folder-smith"] - - -def test_ensure_customer_folder_returns_existing(): - drive = FakeDrive([{"id": "folder-smith", "name": "Smith"}]) - assert ensure_customer_folder(drive, "root123", "Smith") == "folder-smith" - assert drive.files().created is None - - -def test_ensure_customer_folder_creates_when_missing(): - drive = FakeDrive([]) - assert ensure_customer_folder(drive, "root123", "Smith") == "new-id" - assert drive.files().created["body"]["mimeType"] == FOLDER_MIME - assert drive.files().created["body"]["parents"] == ["root123"] - - -def test_find_file_in_folder_hit_and_miss(): - drive = FakeDrive([{"id": "f1", "name": "x.pdf", "webViewLink": "https://drive.google.com/file/d/f1/view"}]) - assert find_file_in_folder(drive, "folder", "x.pdf") == "https://drive.google.com/file/d/f1/view" - assert find_file_in_folder(FakeDrive([]), "folder", "x.pdf") is None diff --git a/tests/test_gcs.py b/tests/test_gcs.py new file mode 100644 index 0000000..1cad70c --- /dev/null +++ b/tests/test_gcs.py @@ -0,0 +1,53 @@ +from app.gcs import blob_name, find_pdf, public_url, upload_pdf + + +class FakeBlob: + def __init__(self, store, name): + self.store, self.name = store, name + self.uploaded = None + + def exists(self): + return self.name in self.store + + def upload_from_string(self, data, content_type=""): + self.store[self.name] = {"data": data, "content_type": content_type} + + +class FakeBucket: + def __init__(self, store): + self.store = store + + def blob(self, name): + return FakeBlob(self.store, name) + + +class FakeStorageClient: + def __init__(self, store=None): + self.store = store if store is not None else {} + + def bucket(self, name): + self.bucket_name = name + return FakeBucket(self.store) + + +def test_blob_name_prefixes_by_customer(): + assert blob_name("Smith", "Smith_Amendment_1.pdf") == "Smith/Smith_Amendment_1.pdf" + assert blob_name("Van Der Berg", "x.pdf") == "Van_Der_Berg/x.pdf" + + +def test_public_url_shape(): + assert public_url("bkt", "Smith/x.pdf") == "https://storage.googleapis.com/bkt/Smith/x.pdf" + + +def test_find_pdf_miss_then_hit(): + client = FakeStorageClient() + assert find_pdf(client, "bkt", "Smith/x.pdf") is None + upload_pdf(client, "bkt", "Smith/x.pdf", b"%PDF-fake") + assert find_pdf(client, "bkt", "Smith/x.pdf") == "https://storage.googleapis.com/bkt/Smith/x.pdf" + + +def test_upload_pdf_sets_content_type_and_returns_url(): + client = FakeStorageClient() + url = upload_pdf(client, "bkt", "Smith/x.pdf", b"%PDF-fake") + assert url == "https://storage.googleapis.com/bkt/Smith/x.pdf" + assert client.store["Smith/x.pdf"]["content_type"] == "application/pdf" diff --git a/tests/test_routes_sign.py b/tests/test_routes_sign.py index d8577ab..5414e84 100644 --- a/tests/test_routes_sign.py +++ b/tests/test_routes_sign.py @@ -13,14 +13,20 @@ def created(client): return client.post("/api/v1/amendments", json={"voice_text": "x"}).json() +def expected_blob(created): + ts = created["amendment_id"].rsplit("_", 1)[-1] + return f"Smith/Smith_Amendment_{ts}.pdf" + + def test_sign_uploads_pdf_and_marks_signed(client, fakes, created, signed_body, monkeypatch): monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") resp = client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) assert resp.status_code == 200 - assert resp.json()["pdf_drive_url"].startswith("https://drive.google.com/") + url = resp.json()["pdf_drive_url"] + assert url == f"https://storage.googleapis.com/test-bucket/{expected_blob(created)}" row = fakes["sheets"]._values.store[0] - assert row[7] == "signed" and row[8].startswith("https://drive.google.com/") - assert fakes["drive"].files().created["body"]["parents"] == ["folder-smith"] + assert row[7] == "signed" and row[8] == url + assert fakes["storage"].store[expected_blob(created)]["data"] == b"%PDF-fake" def test_double_sign_409(client, fakes, created, signed_body, monkeypatch): @@ -38,18 +44,10 @@ def test_sign_rejects_non_png_payload(client, created): assert client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=bad).status_code == 422 -def test_sign_creates_missing_customer_folder(client, fakes, created, signed_body, monkeypatch): - monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") - fakes["drive"].files().listing.clear() - resp = client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) - assert resp.status_code == 200 - assert fakes["drive"].files().created["body"]["parents"] == ["new-id"] # upload landed in the just-created folder - - def test_sign_retry_reuses_existing_pdf(client, fakes, created, signed_body, monkeypatch): monkeypatch.setattr("app.main.render_pdf", lambda html: b"%PDF-fake") - fakes["drive"].files().listing[0]["webViewLink"] = "https://drive.google.com/file/d/existing/view" + fakes["storage"].store[expected_blob(created)] = {"data": b"original", "content_type": "application/pdf"} resp = client.post(f"/api/v1/amendments/{created['amendment_id']}/sign", json=signed_body) assert resp.status_code == 200 - assert resp.json()["pdf_drive_url"] == "https://drive.google.com/file/d/existing/view" - assert fakes["drive"].files().created is None # neither folder nor PDF was re-created + assert resp.json()["pdf_drive_url"].endswith(expected_blob(created)) + assert fakes["storage"].store[expected_blob(created)]["data"] == b"original" # no re-upload on retry