diff --git a/docs/developer-notes/filing-data-integrity.md b/docs/developer-notes/filing-data-integrity.md new file mode 100644 index 00000000..44c09313 --- /dev/null +++ b/docs/developer-notes/filing-data-integrity.md @@ -0,0 +1,30 @@ +# Filing data integrity + +The changes for issues #217 and #218 establish three workflow rules. + +## Extraction evidence and parties + +Extraction normalization removes whole-value missing answers such as `unknown`, `N/A`, and `not provided`, including inside arrays and objects. Display normalization also covers older saved extractions. It preserves meaningful zero/false answers and names such as “All Unknown Occupants.” + +Only captioned plaintiffs/petitioners and defendants/respondents prefill party rows. Other names remain in the supporting evidence disclosure. A filer can explicitly add a person as a party; merely mentioning a child or witness does not add them to the submitted party list. + +## Primary filing type + +The lead `FilingDocument` owns the primary filing type. `FilingDraft.filing_type_code` and `filing_type_name` are synchronized summary fields. Document saves, lead changes, document deletion, and successful submission keep those fields aligned. Legacy case-data edits write through to the lead document. Migration `0022` repairs existing draft summaries, including submitted filings. + +Use model saves when changing document filing types or roles. Bulk updates bypass model synchronization. Supporting documents retain their own filing types. Review and refiling already use the lead document, while draft serialization uses its synchronized summary. + +## Draft identity across tabs + +Workflow URLs carry `?draft=`. The workflow base template provides that page's identity to `draft-scope.js`, which preserves it in links, forms, browser history, and fetch requests. Fetch calls using a `Request` object carry `X-Filing-Draft` to preserve the request body. Server redirects and JSON redirect URLs retain the identity, including the submitted filing's confirmation page. + +Add any new workflow page to `WORKFLOW_VIEWS` in `services/draft_urls.py`. Starting a filing and choosing another draft remain separate navigation actions. The session pointer supports older entry URLs, but reading a draft never falls back to another browser's latest filing. Explicit IDs are checked for ownership, jurisdiction, status, and conflicting parameters before a view runs. Unavailable drafts return HTTP 409; they never select another draft. + +Regression checks, from `efile_app/`: + +```bash +uv run pytest -q efile/tests/test_filing_integrity.py +npx playwright test --config=playwright.draft-scope.config.js +``` + +The Python tests cover persisted data and separate/shared sessions. The browser tests cover links, forms, requests, reloads, and two simultaneous contexts using intercepted responses, without an EFSP account or live filing. diff --git a/efile_app/efile/apps.py b/efile_app/efile/apps.py index bb25fcb4..a7640b4f 100644 --- a/efile_app/efile/apps.py +++ b/efile_app/efile/apps.py @@ -7,4 +7,7 @@ class EfileConfig(AppConfig): def ready(self): # Registers the checks in efile/checks.py by importing them. - from efile import checks # noqa: F401 + from efile import ( + checks, # noqa: F401 + signals, # noqa: F401 + ) diff --git a/efile_app/efile/middleware.py b/efile_app/efile/middleware.py index f0da257a..d216b67e 100644 --- a/efile_app/efile/middleware.py +++ b/efile_app/efile/middleware.py @@ -1,8 +1,59 @@ from django.contrib.auth import logout +from django.http import JsonResponse +from django.shortcuts import render +from django.utils.deprecation import MiddlewareMixin +from efile.models import FilingDraft +from efile.services.current_drafts import DraftIdentityError, resolve_explicit_draft +from efile.services.draft_urls import draft_url from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request +class DraftIdentityMiddleware(MiddlewareMixin): + """Validate named drafts before views run and preserve them in redirects.""" + + def process_view(self, request, view_func, view_args, view_kwargs): + statuses = (FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR) + if request.resolver_match.url_name == "submit_final_filing": + statuses = (*statuses, FilingDraft.Status.SUBMITTING) + if request.resolver_match.url_name == "filing_confirmation": + statuses = (FilingDraft.Status.SUBMITTED,) + try: + resolve_explicit_draft(request, jurisdiction=view_kwargs.get("jurisdiction"), statuses=statuses) + except DraftIdentityError as error: + return self.process_exception(request, error) + + def process_exception(self, request, exception): + if isinstance(exception, DraftIdentityError): + if ( + not request.path.startswith("/api/") + and "application/json" not in request.headers.get("Accept", "") + and request.content_type != "application/json" + ): + return render(request, "efile/draft_unavailable.html", status=409) + return JsonResponse( + { + "success": False, + "error": "This draft is no longer available here. Open My drafts to choose a filing, or start a new one.", + }, + status=409, + ) + + def process_response(self, request, response): + draft = getattr(request, "filing_draft", None) + if draft is not None: + if response.has_header("Location"): + response["Location"] = draft_url(response["Location"], draft.pk) + elif isinstance(response, JsonResponse): + import json + + payload = json.loads(response.content) + if isinstance(payload, dict) and isinstance(payload.get("redirect_url"), str): + payload["redirect_url"] = draft_url(payload["redirect_url"], draft.pk) + response.content = json.dumps(payload) + return response + + class JurisdictionSessionMiddleware: """End an authenticated session before it can cross a jurisdiction boundary.""" diff --git a/efile_app/efile/migrations/0022_sync_primary_filing_type.py b/efile_app/efile/migrations/0022_sync_primary_filing_type.py new file mode 100644 index 00000000..29299891 --- /dev/null +++ b/efile_app/efile/migrations/0022_sync_primary_filing_type.py @@ -0,0 +1,18 @@ +from django.db import migrations + + +def synchronize_primary_types(apps, schema_editor): + Draft = apps.get_model("efile", "FilingDraft") + Document = apps.get_model("efile", "FilingDocument") + database = schema_editor.connection.alias + for draft in Draft.objects.using(database).iterator(): + lead = Document.objects.using(database).filter(draft_id=draft.pk, role="lead").order_by("sort_order", "pk").first() + if lead is not None: + Draft.objects.using(database).filter(pk=draft.pk).update( + filing_type_code=lead.filing_type_code, filing_type_name=lead.filing_type_name + ) + + +class Migration(migrations.Migration): + dependencies = [("efile", "0021_party_is_self")] + operations = [migrations.RunPython(synchronize_primary_types, migrations.RunPython.noop)] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index f463f80c..c8ea90a7 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -1,7 +1,7 @@ # models.py - Optional extension to store additional user information from django.conf import settings from django.contrib.auth.models import AbstractUser -from django.db import models +from django.db import models, transaction from django.utils import timezone from efile.party_sides import PARTY_SIDE_CHOICES @@ -280,6 +280,7 @@ def __str__(self): return f"{self.get_status_display()} filing draft #{self.pk} ({self.jurisdiction})" def mark_submitted(self, response_data): + sync_primary_filing_type(self) self.status = self.Status.SUBMITTED self.current_step = WorkflowStepKey.CONFIRMATION self.submission_response = response_data or {} @@ -291,9 +292,18 @@ def mark_error(self, response_data): self.submission_response = response_data or {} self.save(update_fields=["status", "submission_response", "updated_at"]) + def save(self, *args, **kwargs): + update_fields = kwargs.get("update_fields") + if self.pk and (update_fields is None or {"filing_type_code", "filing_type_name"}.intersection(update_fields)): + lead = self.documents.filter(role=FilingDocument.Role.LEAD).order_by("sort_order", "pk").first() + if lead is not None: + self.filing_type_code = lead.filing_type_code + self.filing_type_name = lead.filing_type_name + super().save(*args, **kwargs) + class FilingDocument(models.Model): - """Uploaded document that belongs to a filing draft.""" + """Uploaded document; the lead's filing type is authoritative for the draft.""" class Role(models.TextChoices): LEAD = "lead", "Lead document" @@ -348,6 +358,27 @@ class Meta: def __str__(self): return self.name or f"{self.get_role_display()} for draft #{self.draft_id}" + @transaction.atomic + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + sync_primary_filing_type(self.draft) + + +def sync_primary_filing_type(draft): + """Persist the lead's filing type as the draft's primary filing summary. + + Query after saving: organization can promote or demote documents in either + order. Also update the caller's draft instance so a later save cannot undo it. + """ + lead = draft.documents.filter(role=FilingDocument.Role.LEAD).order_by("sort_order", "pk").first() + values = { + "filing_type_code": lead.filing_type_code if lead else "", + "filing_type_name": lead.filing_type_name if lead else "", + } + FilingDraft.objects.filter(pk=draft.pk).update(**values) + for field, value in values.items(): + setattr(draft, field, value) + class DocumentExtraction(models.Model): """Durable background work for analyzing one uploaded lead PDF.""" diff --git a/efile_app/efile/prompts/document_evidence_extraction.yaml b/efile_app/efile/prompts/document_evidence_extraction.yaml index b1ded219..96b4ee92 100644 --- a/efile_app/efile/prompts/document_evidence_extraction.yaml +++ b/efile_app/efile/prompts/document_evidence_extraction.yaml @@ -12,11 +12,11 @@ fields: court name: The complete court name, including department, division, unit, county, or venue when shown docket number: The docket or case number and any visible prefix case title: The complete caption or case title - plaintiff or petitioner names: All plaintiff or petitioner names - defendant or respondent names: All defendant or respondent names - other party names: Other named parties with their stated roles + plaintiff or petitioner names: Only people or organizations explicitly identified as plaintiffs or petitioners in the case caption; exclude children, witnesses, and other people merely mentioned in the document + defendant or respondent names: Only people or organizations explicitly identified as defendants or respondents in the case caption; exclude children, witnesses, and other people merely mentioned in the document + other party names: Other names mentioned in the document with their stated roles; these are reviewable evidence, not confirmed case parties document date: The date and its printed label, such as signed, issued, or filed - filing phase: Initial, subsequent, or unknown, only when document evidence supports the value + filing phase: Initial or subsequent, only when document evidence supports the value; otherwise omit requested relief: A concise list of expressly requested orders or remedies monetary amounts: Every expressly stated claim amount, amount in controversy, damages demand, rent due, estate value, or other classification-relevant amount, preserving its printed label selected options: Checked boxes or selected choices that distinguish the proceeding or filing @@ -52,7 +52,7 @@ versions: * Return monetary amounts as a JSON array. Each item must have `label`, `raw`, `amount`, `currency`, and `evidence`. Copy `raw` exactly; use plain decimal digits without currency symbols or separators for `amount`; use `USD` when a dollar sign or dollars label establishes it. Omit a property that the source does not establish. * Keep classification evidence, requested relief, and selected options as JSON arrays. Other fields are strings; join multiple names with semicolons. * Include only evidence relevant to identifying the document or its underlying proceeding. - * Omit unsupported fields. Use "unknown" only for filing phase when the document does not establish it. + * Omit unsupported fields. Do not return placeholders such as "unknown", "N/A", or "not provided". * Do not include addresses, phone numbers, email addresses, financial account numbers, or unrelated allegations in classification evidence. Requested fields: @@ -75,7 +75,7 @@ versions: * Return monetary amounts as a JSON array. Each item must have `label`, `raw`, `amount`, `currency`, and `evidence`. Copy `raw` exactly; use plain decimal digits without currency symbols or separators for `amount`; use `USD` when a dollar sign or dollars label establishes it. Omit a property that the source does not establish. * Keep classification evidence, requested relief, and selected options as JSON arrays. Other fields are strings; join multiple names with semicolons. * Include only evidence relevant to identifying the document or its underlying proceeding. - * Omit unsupported fields. Use "unknown" only for filing phase when the document does not establish it. + * Omit unsupported fields. Do not return placeholders such as "unknown", "N/A", or "not provided". * Do not include addresses, phone numbers, email addresses, financial account numbers, or unrelated allegations in classification evidence. Requested fields: diff --git a/efile_app/efile/services/current_drafts.py b/efile_app/efile/services/current_drafts.py index c40c5d14..e024faa5 100644 --- a/efile_app/efile/services/current_drafts.py +++ b/efile_app/efile/services/current_drafts.py @@ -11,6 +11,38 @@ CURRENT_DRAFT_SESSION_KEY = "filing_draft_id" +class DraftIdentityError(Exception): + """An explicit draft is no longer available for this request.""" + + +def explicit_draft_id(request): + """Read tab-local identity; never substitute the shared session pointer.""" + values = request.GET.getlist("draft") + if request.headers.get("X-Filing-Draft") is not None: + values.append(request.headers["X-Filing-Draft"]) + if request.content_type in {"application/x-www-form-urlencoded", "multipart/form-data"}: + values.extend(request.POST.getlist("draft")) + if not values: + return None + if len(set(values)) != 1 or not values[0].isascii() or not values[0].isdigit() or len(values[0]) > 18: + raise DraftIdentityError + return int(values[0]) + + +def resolve_explicit_draft(request, *, jurisdiction=None, statuses=CURRENT_DRAFT_STATUSES): + draft_id = explicit_draft_id(request) + if draft_id is None: + return None + user = _authenticated_user(request) + draft = ( + get_active_draft(user=user, draft_id=draft_id, jurisdiction=jurisdiction, statuses=statuses) if user else None + ) + if draft is None: + raise DraftIdentityError + request.filing_draft = draft + return draft + + def _authenticated_user(request): user = getattr(request, "user", None) return user if getattr(user, "is_authenticated", False) else None @@ -22,9 +54,13 @@ def attach_current_draft(request, draft: FilingDraft) -> None: request.session[CURRENT_DRAFT_SESSION_KEY] = draft.pk request.session["jurisdiction"] = draft.jurisdiction request.session.modified = True + request.filing_draft = draft def clear_current_draft(request) -> None: + draft = getattr(request, "filing_draft", None) + if draft is not None and request.session.get(CURRENT_DRAFT_SESSION_KEY) != draft.pk: + return if CURRENT_DRAFT_SESSION_KEY in request.session: del request.session[CURRENT_DRAFT_SESSION_KEY] request.session.modified = True @@ -37,6 +73,9 @@ def pointed_at_draft(request, *, jurisdiction: str | None = None) -> FilingDraft supplied) jurisdiction are enforced on every lookup. """ + explicit = resolve_explicit_draft(request, jurisdiction=jurisdiction) + if explicit is not None: + return explicit user = _authenticated_user(request) if user is None: clear_current_draft(request) @@ -61,6 +100,8 @@ def pointed_at_draft(request, *, jurisdiction: str | None = None) -> FilingDraft ) if draft is None: clear_current_draft(request) + else: + request.filing_draft = draft return draft @@ -102,16 +143,12 @@ def get_current_draft( request, *, jurisdiction: str | None = None, - resume_latest: bool = True, + resume_latest: bool = False, ) -> FilingDraft | None: - """Resolve the current user's draft, without ever choosing one for them. - - Reading is not choosing. This used to attach whatever draft it found to the - session, which meant that merely loading a page -- or an API call that page - fired -- could make an old filing the current one, and could do so *after* a - new filing had been started, silently putting the filer back in the old one. - Nothing here writes to the session now: adoption is ``adopt_draft``, and it - happens only where the filer asked for it. + """Resolve a named draft, falling back only to this session's own pointer. + + Only resume offers opt into finding the account's latest draft. APIs must + not read a filing just because another browser recently worked on it. """ draft = pointed_at_draft(request, jurisdiction=jurisdiction) @@ -157,12 +194,9 @@ def ensure_current_draft( from a matter they finished last month. """ - # A named draft wins over the one the session is holding: naming it is the - # filer saying "this one", and they may well be switching away from - # whatever they were last in. - draft = adopt_draft(request, request.GET.get(RESUME_DRAFT_PARAM), jurisdiction=jurisdiction) - if draft is None: - draft = pointed_at_draft(request, jurisdiction=jurisdiction) + # A named draft belongs to this request. Resolving it must not replace the + # shared session pointer, and an invalid identity must never fall through. + draft = pointed_at_draft(request, jurisdiction=jurisdiction) if draft is None: return create_current_draft( request, diff --git a/efile_app/efile/services/draft_urls.py b/efile_app/efile/services/draft_urls.py new file mode 100644 index 00000000..bf2429ba --- /dev/null +++ b/efile_app/efile/services/draft_urls.py @@ -0,0 +1,55 @@ +"""URLs that belong to a particular filing, shared with the browser.""" + +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from django.urls import Resolver404, resolve, reverse + +WORKFLOW_VIEWS = frozenset( + { + "filing_path", + "upload_documents", + "document_extraction_status", + "extraction_review", + "case_lookup", + "case_confirmation", + "document_checklist", + "organize_documents", + "your_information", + "parties", + "party_details", + "case_questions", + "payment", + "case_review", + "filing_confirmation", + "expert_form", + "upload_first", + "upload", + } +) + + +def draft_url(url, draft_id): + """Carry identity through workflow redirects, preserving other query parameters.""" + parts = urlsplit(url) + if parts.netloc or parts.scheme: + return url + try: + if resolve(parts.path).url_name not in WORKFLOW_VIEWS: + return url + except Resolver404: + return url + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query.setdefault("draft", str(draft_id)) + return urlunsplit(parts._replace(query=urlencode(query))) + + +def browser_draft_context(request): + draft = getattr(request, "filing_draft", None) + if draft is None: + return {} + return { + "draft_scope": { + "id": draft.pk, + "paths": [reverse(name, kwargs={"jurisdiction": draft.jurisdiction}) for name in sorted(WORKFLOW_VIEWS)], + } + } diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 74a0990b..a31221b7 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -227,6 +227,20 @@ def write_case_data( draft.current_step = str(current_step) update_fields.append("current_step") + # Legacy clients still send the primary type here. Write explicit edits + # through to the authoritative lead before saving the summary. + filing_fields = { + field: _as_str(value) + for field in ("filing_type_code", "filing_type_name") + if (value := _first_present(data, _DRAFT_FIELD_SOURCES[field])) is not _MISSING + } + if filing_fields: + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + if lead is not None: + for field, value in filing_fields.items(): + setattr(lead, field, value) + lead.save(update_fields=[*filing_fields, "updated_at"]) + if update_fields: draft.save(update_fields=sorted({*update_fields, "updated_at"})) diff --git a/efile_app/efile/services/extracted_parties.py b/efile_app/efile/services/extracted_parties.py index 271eef63..1e4bb596 100644 --- a/efile_app/efile/services/extracted_parties.py +++ b/efile_app/efile/services/extracted_parties.py @@ -273,6 +273,10 @@ def review_rows(draft: FilingDraft) -> list[dict[str, Any]]: "is_organization": looks_like_organization(entry["name"]), } for entry in extracted_party_suggestions(draft.extracted_guesses) + # Mentioning a child, witness, guardian, or other person does not + # establish that they are a case party. Keep these names in the + # supporting evidence; the filer can explicitly add a party if needed. + if entry["side"] in {PartySide.INITIATING, PartySide.RESPONDING} ] diff --git a/efile_app/efile/services/extraction_fields.py b/efile_app/efile/services/extraction_fields.py index 7bdf508a..c747bbea 100644 --- a/efile_app/efile/services/extraction_fields.py +++ b/efile_app/efile/services/extraction_fields.py @@ -1,5 +1,7 @@ """The complete, reviewable set of details requested from a lead document.""" +import re + from efile.utils.prompt_config import load_prompt DOCUMENT_EXTRACTION_PROMPT = load_prompt("document_evidence_extraction") @@ -28,7 +30,7 @@ "case title": "Case title", "plaintiff or petitioner names": "Plaintiff or petitioner names", "defendant or respondent names": "Defendant or respondent names", - "other party names": "Other party names", + "other party names": "Other names mentioned in the document", "document date": "Document date", "filing phase": "Filing phase", "requested relief": "Requested relief", @@ -39,13 +41,56 @@ } +_PLACEHOLDERS = frozenset( + { + "unknown", + "none", + "null", + "n a", + "na", + "not applicable", + "not available", + "not provided", + "not specified", + "not stated", + "not found", + "not listed", + "unspecified", + "blank", + "empty", + "tbd", + "to be determined", + } +) + + +def clean_extracted_value(value): + """Discard missing-value answers, recursively, without matching inside facts. + + Keep meaningful zero/false answers and names such as All Unknown Occupants. + This also runs at display time so older saved extractions are covered. + """ + if isinstance(value, str): + value = value.strip() + comparable = re.sub(r"[^a-z0-9]+", " ", value.casefold()).strip() + return None if not value or comparable in _PLACEHOLDERS else value + if isinstance(value, dict): + return { + key: cleaned for key, item in value.items() if (cleaned := clean_extracted_value(item)) is not None + } or None + if isinstance(value, list | tuple | set): + return [cleaned for item in value if (cleaned := clean_extracted_value(item)) is not None] or None + return value + + def normalize_extracted_fields(found_fields): - """Keep every extracted value while normalizing keys used by the workflow.""" + """Keep supported extracted values and normalize keys used by the workflow.""" if not isinstance(found_fields, dict): return {} normalized = {} for raw_key, value in found_fields.items(): + value = clean_extracted_value(value) if value in (None, "", [], {}): continue key = str(raw_key).strip().lower() @@ -69,6 +114,7 @@ def normalize_document_evidence(found_fields): return {} normalized = {} for raw_key, value in found_fields.items(): + value = clean_extracted_value(value) if value in (None, "", [], {}): continue key = str(raw_key).strip().lower() @@ -86,6 +132,7 @@ def display_extracted_fields(found_fields): return {} display = {} for raw_key, value in found_fields.items(): + value = clean_extracted_value(value) if value in (None, "", [], {}): continue key = str(raw_key).strip().lower() @@ -122,7 +169,6 @@ def display_extracted_fields(found_fields): "case title", "plaintiff or petitioner names", "defendant or respondent names", - "other party names", } ) @@ -139,7 +185,7 @@ def display_extracted_fields(found_fields): def extracted_details(guesses): """Return every extracted item in a stable, user-facing order.""" - guesses = guesses or {} + guesses = display_extracted_fields(guesses or {}) ordered_keys = [*EXTRACTION_LABELS, *(key for key in guesses if key not in EXTRACTION_LABELS)] return [ { diff --git a/efile_app/efile/settings_base.py b/efile_app/efile/settings_base.py index e62c576c..7f53bd73 100644 --- a/efile_app/efile/settings_base.py +++ b/efile_app/efile/settings_base.py @@ -48,6 +48,7 @@ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "efile.middleware.JurisdictionSessionMiddleware", + "efile.middleware.DraftIdentityMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "efile.middleware.NoCacheHTMLMiddleware", @@ -66,6 +67,7 @@ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "efile.context_processors.jurisdiction_context", + "efile.services.draft_urls.browser_draft_context", ], }, }, diff --git a/efile_app/efile/signals.py b/efile_app/efile/signals.py new file mode 100644 index 00000000..382998d3 --- /dev/null +++ b/efile_app/efile/signals.py @@ -0,0 +1,13 @@ +"""Keep primary filing summaries current when documents are deleted in bulk.""" + +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from efile.models import FilingDocument, FilingDraft, sync_primary_filing_type + + +@receiver(post_delete, sender=FilingDocument) +def synchronize_deleted_document(sender, instance, **kwargs): + draft = FilingDraft.objects.filter(pk=instance.draft_id).first() + if draft is not None: + sync_primary_filing_type(draft) diff --git a/efile_app/efile/static/js/draft-scope.js b/efile_app/efile/static/js/draft-scope.js new file mode 100644 index 00000000..2b54d641 --- /dev/null +++ b/efile_app/efile/static/js/draft-scope.js @@ -0,0 +1,65 @@ +/* Keep this page's filing identity in its URLs, independently of shared cookies. */ +(() => { + const scope = JSON.parse(document.getElementById('draft-scope')?.textContent || 'null'); + if (!scope?.id) return; + const paths = new Set(scope.paths); + + function withDraft(value, includeApi = false) { + const url = new URL(value, window.location.href); + if (url.origin !== window.location.origin) return value; + if (!paths.has(url.pathname) && !(includeApi && url.pathname.startsWith('/api/'))) return value; + // Resume links already name their target; never replace that selection. + if (!url.searchParams.has('draft')) url.searchParams.set('draft', scope.id); + return url.href; + } + window.withFilingDraft = withDraft; + + // A direct visit to an old, unscoped workflow URL adopts a session draft + // once. Reload, history navigation, and subsequent requests stay with it. + window.history.replaceState(window.history.state, '', withDraft(window.location.href)); + + const originalFetch = window.fetch.bind(window); + window.fetch = (input, options) => { + const originalUrl = input instanceof Request ? input.url : String(input); + const scopedUrl = withDraft(originalUrl, true); + if (input instanceof Request) { + // Preserve the Request's body, method, headers, and cancellation. + // Reconstructing it from a URL can discard a streaming body. + const request = new Request(input, options); + if (scopedUrl !== originalUrl) { + request.headers.set('X-Filing-Draft', new URL(scopedUrl).searchParams.get('draft')); + } + return originalFetch(request); + } + return originalFetch(scopedUrl, options); + }; + + function scopeElements(root) { + root.querySelectorAll('a[href], form').forEach(element => { + const isForm = element.tagName === 'FORM'; + const attribute = isForm ? 'action' : 'href'; + const original = element.getAttribute(attribute) || window.location.href; + const scoped = withDraft(original); + element.setAttribute(attribute, scoped); + // Browsers replace the action query when submitting GET forms. + if (isForm && paths.has(new URL(scoped, window.location.href).pathname)) { + let identity = element.querySelector('input[name="draft"]'); + if (!identity) { + identity = document.createElement('input'); + identity.type = 'hidden'; + identity.name = 'draft'; + element.append(identity); + } + identity.value = new URL(scoped, window.location.href).searchParams.get('draft'); + } + }); + } + document.addEventListener('DOMContentLoaded', () => { + scopeElements(document); + // Also cover links/party forms inserted by the page's own scripts. + new MutationObserver(() => scopeElements(document)).observe(document.body, { + childList: true, + subtree: true + }); + }); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/payment.js b/efile_app/efile/static/js/payment.js index c84c296b..4056f36c 100644 --- a/efile_app/efile/static/js/payment.js +++ b/efile_app/efile/static/js/payment.js @@ -186,8 +186,8 @@ const PaymentPage = { global: "false", type_code: "CC", tyler_info: authData.data.tyler_token, - original_url: `${window.location.origin}/jurisdiction/${jurisdiction}/payment/?payment_status=success`, - error_url: `${window.location.origin}/jurisdiction/${jurisdiction}/payment/?payment_status=failure` + original_url: window.withFilingDraft(`${window.location.origin}/jurisdiction/${jurisdiction}/payment/?payment_status=success`), + error_url: window.withFilingDraft(`${window.location.origin}/jurisdiction/${jurisdiction}/payment/?payment_status=failure`) }; Object.entries(fields).forEach(([name, value]) => { const input = document.createElement("input"); diff --git a/efile_app/efile/templates/efile/draft_unavailable.html b/efile_app/efile/templates/efile/draft_unavailable.html new file mode 100644 index 00000000..b5fea20a --- /dev/null +++ b/efile_app/efile/templates/efile/draft_unavailable.html @@ -0,0 +1,14 @@ +{% extends "efile/site_base.html" %} +{% load i18n %} +{% block title %} + {% translate "Draft unavailable" %} +{% endblock title %} +{% block public_content %} +

{% translate "This draft is no longer available here" %}

+

{% translate "It may have been submitted or deleted, or belong to a different account. Open My drafts to choose a filing, or start a new one." %}

+ {% if jurisdiction %} + {% translate "Open My drafts" %} + {% else %} + {% translate "Choose a jurisdiction" %} + {% endif %} +{% endblock public_content %} diff --git a/efile_app/efile/templates/efile/workflow_base.html b/efile_app/efile/templates/efile/workflow_base.html index 5b809d5a..b3f5e07d 100644 --- a/efile_app/efile/templates/efile/workflow_base.html +++ b/efile_app/efile/templates/efile/workflow_base.html @@ -5,6 +5,8 @@ + {{ draft_scope|json_script:"draft-scope" }} + {% block title %} {% translate "Make a court filing" %} diff --git a/efile_app/efile/tests/test_case_confirmation.py b/efile_app/efile/tests/test_case_confirmation.py index c50f5c0a..93feae49 100644 --- a/efile_app/efile/tests/test_case_confirmation.py +++ b/efile_app/efile/tests/test_case_confirmation.py @@ -29,7 +29,7 @@ def test_new_case_skips_case_lookup(client, django_user_model): response = client.get(reverse("case_lookup", kwargs={"jurisdiction": "illinois"})) assert response.status_code == 302 - assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) @pytest.mark.django_db @@ -53,7 +53,7 @@ def test_case_lookup_result_is_persisted_on_the_draft(client, django_user_model) ) assert response.status_code == 200 - assert response.json()["redirect_url"] == reverse( + assert response.json()["redirect_url"].partition("?")[0] == reverse( "case_confirmation", kwargs={"jurisdiction": "illinois"}, ) @@ -79,7 +79,7 @@ def test_case_confirmation_accepts_case_and_converges_on_checklist(client, djang ) assert response.status_code == 302 - assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) draft.refresh_from_db() assert draft.current_step == WorkflowStepKey.DOCUMENT_CHECKLIST @@ -98,7 +98,7 @@ def test_case_confirmation_rejection_clears_result_and_returns_to_lookup(client, ) assert response.status_code == 302 - assert response.url == reverse("case_lookup", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_lookup", kwargs={"jurisdiction": "illinois"}) draft.refresh_from_db() assert draft.previous_case_id == "" assert draft.docket_number == "" diff --git a/efile_app/efile/tests/test_current_draft_selection.py b/efile_app/efile/tests/test_current_draft_selection.py index d703c8ad..c7248712 100644 --- a/efile_app/efile/tests/test_current_draft_selection.py +++ b/efile_app/efile/tests/test_current_draft_selection.py @@ -147,8 +147,8 @@ def test_resuming_a_filing_by_name_picks_it_back_up(signed_in, last_months_filin assert page.context["filing_draft"]["id"] == last_months_filing.pk assert [document.name for document in page.context["documents"]] == ["last-months-petition.pdf"] - # And it stays picked up, without the URL having to say so again. - assert current_draft_id(signed_in) == last_months_filing.pk + # Resuming names this page's draft without replacing another tab's pointer. + assert current_draft_id(signed_in) is None @pytest.mark.django_db @@ -162,7 +162,7 @@ def test_resuming_switches_away_from_the_filing_you_were_in(signed_in, last_mont page = signed_in.get(f"{UPLOAD_URL}?draft={last_months_filing.pk}") assert page.context["filing_draft"]["id"] == last_months_filing.pk - assert current_draft_id(signed_in) == last_months_filing.pk + assert current_draft_id(signed_in) == new_draft_id @pytest.mark.django_db @@ -176,8 +176,8 @@ def test_you_cannot_resume_someone_elses_filing(signed_in, last_months_filing, d page = signed_in.get(f"{UPLOAD_URL}?draft={last_months_filing.pk}") - assert page.context["filing_draft"]["id"] != last_months_filing.pk - assert list(page.context["documents"]) == [] + assert page.status_code == 409 + assert not FilingDraft.objects.filter(user=intruder).exists() @pytest.mark.django_db @@ -186,4 +186,4 @@ def test_a_filing_from_another_jurisdiction_is_not_resumed(signed_in, last_month page = signed_in.get(f"{UPLOAD_URL}?draft={last_months_filing.pk}") - assert page.context["filing_draft"]["id"] != last_months_filing.pk + assert page.status_code == 409 diff --git a/efile_app/efile/tests/test_document_extractions.py b/efile_app/efile/tests/test_document_extractions.py index 169c288f..81be394a 100644 --- a/efile_app/efile/tests/test_document_extractions.py +++ b/efile_app/efile/tests/test_document_extractions.py @@ -189,7 +189,7 @@ def test_review_waits_for_background_analysis(client, extraction_draft): response = client.get(reverse("extraction_review", kwargs={"jurisdiction": "illinois"})) assert response.status_code == 302 - assert response.url == reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) @pytest.mark.django_db diff --git a/efile_app/efile/tests/test_document_prep.py b/efile_app/efile/tests/test_document_prep.py index ce1f36a0..de7ca52a 100644 --- a/efile_app/efile/tests/test_document_prep.py +++ b/efile_app/efile/tests/test_document_prep.py @@ -61,7 +61,7 @@ def test_document_checklist_continues_to_organize(client, document_draft): document_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) assert document_draft.document_checklist_acknowledged is True assert document_draft.current_step == WorkflowStepKey.ORGANIZE_DOCUMENTS @@ -132,7 +132,7 @@ def test_document_checklist_saves_gathered_documents(client, planned_draft): planned_draft.refresh_from_db() checklist = planned_draft.plan.checklist assert response.status_code == 302 - assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) assert checklist["petition"]["status"] == "have" assert checklist["proposed_order"]["status"] == "filed" assert checklist["publication_notice"]["status"] == "" @@ -149,7 +149,7 @@ def test_document_checklist_saves_gathered_documents_when_continuing(client, pla planned_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) assert planned_draft.document_checklist_acknowledged is True assert planned_draft.plan.checklist["petition"]["status"] == "have" @@ -159,7 +159,7 @@ def test_organize_requires_completed_checklist(client, document_draft): response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) assert response.status_code == 302 - assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) @pytest.mark.django_db @@ -171,7 +171,7 @@ def test_organize_redirects_when_court_is_missing(client, document_draft): response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) assert response.status_code == 302 - assert response.url == reverse("extraction_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("extraction_review", kwargs={"jurisdiction": "illinois"}) @pytest.mark.django_db @@ -200,7 +200,9 @@ def test_organize_returns_to_review_when_edited_from_there(client, document_draf document_draft.refresh_from_db() assert response.status_code == 200 - assert response.json()["redirect_url"] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.json()["redirect_url"].partition("?")[0] == reverse( + "case_review", kwargs={"jurisdiction": "illinois"} + ) assert document_draft.current_step == WorkflowStepKey.REVIEW @@ -302,7 +304,9 @@ def test_organize_saves_details_and_supporting_order(client, document_draft): document_draft.refresh_from_db() lead.refresh_from_db() assert response.status_code == 200 - assert response.json()["redirect_url"] == reverse("your_information", kwargs={"jurisdiction": "illinois"}) + assert response.json()["redirect_url"].partition("?")[0] == reverse( + "your_information", kwargs={"jurisdiction": "illinois"} + ) assert document_draft.current_step == WorkflowStepKey.YOUR_INFORMATION assert lead.role == FilingDocument.Role.SUPPORTING assert lead.filing_type_code == "petition" @@ -311,6 +315,10 @@ def test_organize_saves_details_and_supporting_order(client, document_draft): assert lead.filing_requires_amount_in_controversy is True second.refresh_from_db() assert second.role == FilingDocument.Role.LEAD + assert (document_draft.filing_type_code, document_draft.filing_type_name) == ( + second.filing_type_code, + second.filing_type_name, + ) assert list( document_draft.documents.filter(role=FilingDocument.Role.SUPPORTING) .order_by("sort_order") diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py index 6fcf8936..3e1c6d74 100644 --- a/efile_app/efile/tests/test_durable_drafts.py +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -401,7 +401,7 @@ def test_current_draft_enforces_owner(client, django_user_model): illinois_user = django_user_model.objects.create_user(username="illinois-user", tyler_jurisdiction="illinois") other_user = django_user_model.objects.create_user(username="other-user", tyler_jurisdiction="massachusetts") other_draft = FilingDraft.objects.create(user=other_user, jurisdiction="illinois") - expected_draft = FilingDraft.objects.create(user=illinois_user, jurisdiction="illinois") + FilingDraft.objects.create(user=illinois_user, jurisdiction="illinois") client.force_login(illinois_user) session = client.session @@ -411,23 +411,27 @@ def test_current_draft_enforces_owner(client, django_user_model): response = client.get(reverse("get_current_draft")) assert response.status_code == 200 - assert response.json()["data"]["filing_draft"]["id"] == expected_draft.pk + assert response.json()["data"]["filing_draft"] is None @pytest.mark.django_db def test_current_draft_does_not_cross_jurisdictions(client, django_user_model): user = django_user_model.objects.create_user(username="multi-state-user", tyler_jurisdiction="illinois") - illinois_draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") + FilingDraft.objects.create(user=user, jurisdiction="illinois") massachusetts_draft = FilingDraft.objects.create(user=user, jurisdiction="massachusetts") client.force_login(user) session = client.session session[CURRENT_DRAFT_SESSION_KEY] = massachusetts_draft.pk session.save() - request = type("Request", (), {"user": user, "session": client.session})() + from django.test import RequestFactory + + request = RequestFactory().get("/") + request.user = user + request.session = client.session current = get_current_draft(request, jurisdiction="illinois") - assert current == illinois_draft + assert current is None @pytest.mark.django_db @@ -671,9 +675,13 @@ def test_route_jurisdiction_isolates_reads(client, django_user_model): session = client.session session[CURRENT_DRAFT_SESSION_KEY] = massachusetts_draft.pk session.save() - request = type("Request", (), {"user": user, "session": client.session})() + from django.test import RequestFactory + + request = RequestFactory().get("/") + request.user = user + request.session = client.session - assert get_case_data(request, jurisdiction="illinois").get("court") == "cook:cd" + assert get_case_data(request, jurisdiction="illinois") == {} @pytest.mark.django_db diff --git a/efile_app/efile/tests/test_end_to_end_new_flow_states.py b/efile_app/efile/tests/test_end_to_end_new_flow_states.py index 0309dea8..e6117e67 100644 --- a/efile_app/efile/tests/test_end_to_end_new_flow_states.py +++ b/efile_app/efile/tests/test_end_to_end_new_flow_states.py @@ -145,7 +145,7 @@ def test_complete_new_filing_flow_by_jurisdiction( {"existing_case": ExistingCase.NEW}, ) assert path_response.status_code == 302 - assert path_response.url == reverse("upload_documents", kwargs={"jurisdiction": jurisdiction}) + assert path_response.url.partition("?")[0] == reverse("upload_documents", kwargs={"jurisdiction": jurisdiction}) draft.refresh_from_db() assert draft.existing_case == ExistingCase.NEW assert draft.current_step == WorkflowStepKey.UPLOAD_DOCUMENTS @@ -251,7 +251,7 @@ def fake_download(_key, destination): }, ) assert ext_post.status_code == 302 - assert ext_post.url == reverse("document_checklist", kwargs={"jurisdiction": jurisdiction}) + assert ext_post.url.partition("?")[0] == reverse("document_checklist", kwargs={"jurisdiction": jurisdiction}) draft.refresh_from_db() assert draft.court_code == court_code @@ -263,7 +263,7 @@ def fake_download(_key, destination): {"documents_complete": "yes"}, ) assert checklist_resp.status_code == 302 - assert checklist_resp.url == reverse("organize_documents", kwargs={"jurisdiction": jurisdiction}) + assert checklist_resp.url.partition("?")[0] == reverse("organize_documents", kwargs={"jurisdiction": jurisdiction}) draft.refresh_from_db() assert draft.document_checklist_acknowledged is True @@ -302,7 +302,9 @@ def fake_download(_key, destination): ) assert org_resp.status_code == 200 assert org_resp.json()["success"] is True - assert org_resp.json()["redirect_url"] == reverse("your_information", kwargs={"jurisdiction": jurisdiction}) + assert org_resp.json()["redirect_url"].partition("?")[0] == reverse( + "your_information", kwargs={"jurisdiction": jurisdiction} + ) draft.refresh_from_db() lead_doc.refresh_from_db() @@ -326,7 +328,7 @@ def fake_download(_key, destination): }, ) assert your_info_resp.status_code == 302 - assert your_info_resp.url == reverse("parties", kwargs={"jurisdiction": jurisdiction}) + assert your_info_resp.url.partition("?")[0] == reverse("parties", kwargs={"jurisdiction": jurisdiction}) draft.refresh_from_db() filer_party = draft.parties.get(role="filer") @@ -373,7 +375,7 @@ def fake_download(_key, destination): }, ) assert party_details_resp.status_code == 302 - assert party_details_resp.url == reverse("payment", kwargs={"jurisdiction": jurisdiction}) + assert party_details_resp.url.partition("?")[0] == reverse("payment", kwargs={"jurisdiction": jurisdiction}) draft.refresh_from_db() other_party.refresh_from_db() @@ -390,7 +392,7 @@ def fake_download(_key, destination): }, ) assert pay_resp.status_code == 302 - assert pay_resp.url == reverse("case_review", kwargs={"jurisdiction": jurisdiction}) + assert pay_resp.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": jurisdiction}) draft.refresh_from_db() assert draft.selected_payment_account_id == "waiver-account-1" diff --git a/efile_app/efile/tests/test_extracted_parties.py b/efile_app/efile/tests/test_extracted_parties.py index 27df405b..3e3f840e 100644 --- a/efile_app/efile/tests/test_extracted_parties.py +++ b/efile_app/efile/tests/test_extracted_parties.py @@ -118,7 +118,7 @@ def test_match_prefers_the_plain_party_type_over_a_compound_one(): @pytest.mark.django_db -def test_review_screen_shows_every_name_with_its_side_chosen(client, review_draft): +def test_review_screen_only_preselects_captioned_case_parties(client, review_draft): authorize(client, review_draft) content = client.get(reverse("extraction_review", kwargs={"jurisdiction": "illinois"})).content.decode() @@ -127,10 +127,11 @@ def test_review_screen_shows_every_name_with_its_side_chosen(client, review_draf assert listing is not None listed = listing.group(1) rows = re.findall(r'name="party_name"\s+value="([^"]*)"', listed) - assert rows == ["Alex Rivera", "Riverbend Properties LLC", "Morgan Lee", "Pat Lee"] + assert rows == ["Alex Rivera", "Riverbend Properties LLC", "Morgan Lee"] chosen = re.findall(r'<option value="([a-z]+)"\s*selected>', listed) - assert chosen == ["initiating", "initiating", "responding", "other"] - assert "Guardian ad Litem" in listed + assert chosen == ["initiating", "initiating", "responding"] + assert "Guardian ad Litem" not in listed + assert "Pat Lee (Guardian ad Litem)" in content @pytest.mark.django_db @@ -428,7 +429,7 @@ def test_the_party_screen_maps_sides_and_asks_only_for_what_is_missing(client, r assert responding.party_type == "defendant" assert FilingParty.objects.filter(draft=review_draft, role="other").count() == 1 assert response.status_code == 302 - assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("payment", kwargs={"jurisdiction": "illinois"}) # --- Answers that are not names ---------------------------------------------- @@ -554,7 +555,7 @@ def test_the_review_screen_offers_the_tick_on_every_person_it_found(client, revi listing = re.search(r'id="review-parties-list">(.*?)</ol>', content, re.S) assert listing is not None - assert listing.group(1).count('name="party_is_self"') == 4 + assert listing.group(1).count('name="party_is_self"') == 3 assert "This is me" in content assert "If one of them is you, say so here" in content @@ -571,9 +572,9 @@ def test_a_company_is_not_offered_as_the_person_filing(client, review_draft): listing = re.search(r'id="review-parties-list">(.*?)</ol>', content, re.S) assert listing is not None rows = listing.group(1) - # Four people, one of them Riverbend Properties LLC. - assert rows.count('name="party_is_self"') == 4 - assert rows.count("review-party__is-me-toggle") == 3 + # Three captioned parties, one of them Riverbend Properties LLC. + assert rows.count('name="party_is_self"') == 3 + assert rows.count("review-party__is-me-toggle") == 2 @pytest.mark.django_db diff --git a/efile_app/efile/tests/test_filing_integrity.py b/efile_app/efile/tests/test_filing_integrity.py new file mode 100644 index 00000000..adc56f6a --- /dev/null +++ b/efile_app/efile/tests/test_filing_integrity.py @@ -0,0 +1,263 @@ +"""Regression coverage for extraction and simultaneous filings (#217, #218).""" + +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +import pytest +from django.test import Client +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.drafts import read_case_data, write_case_data +from efile.services.extracted_parties import review_rows, save_reviewed_parties +from efile.services.extraction_fields import ( + display_extracted_fields, + normalize_document_evidence, + normalize_extracted_fields, + supporting_details, +) + + +@pytest.mark.parametrize( + "normalize", [normalize_document_evidence, normalize_extracted_fields, display_extracted_fields] +) +def test_extraction_discards_missing_answers_without_discarding_facts(normalize): + result = normalize( + { + "form revision": " Unknown. ", + "docket number": "N/A", + "document date": "not provided", + "filing phase": "unknown", + "case title": "All Unknown Occupants", + "requested relief": ["unknown", "Possession", "N/A"], + "selected options": {"unknown answer": "Unknown", "has children": False, "amount": 0}, + } + ) + assert not {"form revision", "docket number", "document date", "filing phase"}.intersection(result) + assert result["case title"] == "All Unknown Occupants" + assert "Possession" in result["requested relief"] + assert "unknown" not in str(result["requested relief"]) + assert "unknown answer" not in result["selected options"] + assert "False" in str(result["selected options"]) + assert "0" in str(result["selected options"]) + + +@pytest.fixture +def draft(db, django_user_model): + user = django_user_model.objects.create_user(username="integrity-user", tyler_jurisdiction="illinois") + return FilingDraft.objects.create(user=user, jurisdiction="illinois", existing_case="new") + + +def signed_in(user): + client = Client() + client.force_login(user) + session = client.session + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "test-token"} + session.save() + return client + + +def route(name, draft): + return reverse(name, kwargs={"jurisdiction": draft.jurisdiction}) + f"?draft={draft.pk}" + + +def test_family_form_children_remain_evidence_until_explicitly_added(draft): + draft.case_type_name = "Dissolution of marriage with children" + draft.extracted_guesses = { + "plaintiff or petitioner names": "Dana Kim", + "defendant or respondent names": "Elliot Kim", + "other party names": "Jamie Kim (Child); Robin Kim (Child)", + } + draft.save() + rows = review_rows(draft) + assert [row["name"] for row in rows] == ["Dana Kim", "Elliot Kim"] + assert "Jamie Kim" in supporting_details(draft.extracted_guesses)[0]["value"] + save_reviewed_parties(draft, rows) + assert list(draft.parties.values_list("first_name", flat=True)) == ["Dana", "Elliot"] + + # Explicitly adding a party remains possible, including a minor petitioner. + rows = review_rows(draft) + rows.append({"name": "Jamie Kim", "side": "initiating"}) + save_reviewed_parties(draft, rows) + assert FilingParty.objects.filter(draft=draft, first_name="Jamie").exists() + + +def test_old_extraction_placeholders_are_not_prefilled_or_displayed(draft): + draft.extracted_guesses = {"case title": "unknown", "docket number": "N/A", "form revision": "Not provided"} + draft.save() + FilingDocument.objects.create(draft=draft, role="lead", name="petition.pdf") + response = signed_in(draft.user).get(route("extraction_review", draft)) + assert response.status_code == 200 + assert response.context["document_summary_details"] == [] + assert response.context["supporting_details"] == [] + assert not response.context["docket_number"] + assert not response.context["case_title"] + assert response.context["extraction_context"]["guesses"] == {} + + +@pytest.mark.parametrize("shared_session", [False, True], ids=["two-browsers", "two-tabs"]) +def test_each_context_keeps_its_draft_after_another_starts_or_resumes(draft, shared_session): + first = signed_in(draft.user) + second = signed_in(draft.user) + if shared_session: + second.cookies = first.cookies.copy() + start_url = reverse("start_filing", kwargs={"jurisdiction": "illinois"}) + first_url = first.post(start_url, {"existing_case": "new"}).url + first_id = int(parse_qs(urlsplit(first_url).query)["draft"][0]) + second_url = second.post(start_url, {"existing_case": "existing"}).url + second_id = int(parse_qs(urlsplit(second_url).query)["draft"][0]) + assert first_id != second_id + + # The second tab can even resume a third matter without changing either URL. + assert second.get(route("upload_documents", draft)).status_code == 200 + for client, url, draft_id, title in [ + (first, first_url, first_id, "Dana's filing"), + (second, second_url, second_id, "Elliot's filing"), + ]: + assert client.get(url).context["filing_draft"]["id"] == draft_id + response = client.post( + reverse("save_case_data_api") + f"?draft={draft_id}", + {"case_title": title}, + content_type="application/json", + ) + assert response.status_code == 200 + assert FilingDraft.objects.get(pk=draft_id).case_title == title + data = client.get(reverse("get_current_draft") + f"?draft={draft_id}").json() + assert data["data"]["filing_draft"]["id"] == draft_id + + +@pytest.mark.parametrize("identity", ["", "unknown", "0", "999999999", "1&draft=2", "9" * 100]) +def test_invalid_identity_never_writes_the_session_draft(draft, identity): + client = signed_in(draft.user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session.save() + response = client.post( + reverse("save_case_data_api") + f"?draft={identity}", + {"case_title": "wrong draft"}, + content_type="application/json", + ) + assert response.status_code == 409 + draft.refresh_from_db() + assert draft.case_title == "" + + +@pytest.mark.parametrize( + "status", [FilingDraft.Status.SUBMITTED, FilingDraft.Status.ABANDONED, FilingDraft.Status.SUBMITTING] +) +def test_stale_identity_is_rejected_without_creating_another_draft(draft, status): + draft.status = status + draft.save() + client = signed_in(draft.user) + before = FilingDraft.objects.count() + assert client.post(route("filing_path", draft), {"existing_case": "existing"}).status_code == 409 + assert FilingDraft.objects.count() == before + + +def test_no_session_pointer_does_not_read_another_contexts_latest_draft(draft): + assert signed_in(draft.user).get(reverse("get_current_draft")).json()["data"]["filing_draft"] is None + + +def test_header_identity_and_conflicting_form_identity(draft): + client = signed_in(draft.user) + response = client.post( + reverse("save_case_data_api"), + {"case_title": "Named in header"}, + content_type="application/json", + HTTP_X_FILING_DRAFT=str(draft.pk), + ) + assert response.status_code == 200 + response = client.post(route("filing_path", draft), {"draft": str(draft.pk + 1), "existing_case": "existing"}) + assert response.status_code == 409 + draft.refresh_from_db() + assert draft.case_title == "Named in header" + assert draft.existing_case == "new" + + +def test_backfill_repairs_existing_submitted_draft_summary(draft): + from importlib import import_module + from types import SimpleNamespace + + from django.apps import apps + from django.db import connection + + FilingDocument.objects.create(draft=draft, role="lead", filing_type_code="27959", filing_type_name="Complaint") + FilingDraft.objects.filter(pk=draft.pk).update( + status=FilingDraft.Status.SUBMITTED, filing_type_code="", filing_type_name="" + ) + migration = vars(import_module("efile.migrations.0022_sync_primary_filing_type")) + migration["synchronize_primary_types"](apps, SimpleNamespace(connection=connection)) + draft.refresh_from_db() + assert (draft.filing_type_code, draft.filing_type_name) == ("27959", "Complaint") + + +def test_primary_type_tracks_edits_clearing_and_lead_deletion(draft): + lead = FilingDocument.objects.create( + draft=draft, role="lead", filing_type_code="27959", filing_type_name="Complaint" + ) + stale_draft = FilingDraft.objects.get(pk=draft.pk) + lead.filing_type_code = "123" + lead.filing_type_name = "Petition" + lead.save(update_fields=["filing_type_code", "filing_type_name"]) + stale_draft.case_title = "A corrected caption" + stale_draft.save() + draft.refresh_from_db() + assert (draft.filing_type_code, draft.filing_type_name) == ("123", "Petition") + write_case_data(draft, {"filing_type": "456", "filing_type_name": "Motion"}) + lead.refresh_from_db() + assert (lead.filing_type_code, lead.filing_type_name) == ("456", "Motion") + assert read_case_data(draft)["filing_type"] == "456" + lead.filing_type_code = lead.filing_type_name = "" + lead.save() + draft.refresh_from_db() + assert (draft.filing_type_code, draft.filing_type_name) == ("", "") + lead.filing_type_code, lead.filing_type_name = "27959", "Complaint" + lead.save() + draft.documents.all().delete() + draft.refresh_from_db() + assert (draft.filing_type_code, draft.filing_type_name) == ("", "") + + +def test_organization_and_submission_preserve_primary_type_and_confirmation_identity(draft): + from efile.tests.test_durable_drafts import FakeApiResponse, _prepare_submission + + client = signed_in(draft.user) + _prepare_submission(client, draft) + draft.refresh_from_db() + draft.document_checklist_acknowledged = True + draft.save() + lead = draft.documents.get(role="lead") + details = [{"id": lead.pk, "filing_type": "27959", "filing_type_name": "Complaint", "document_type": "public"}] + response = client.post( + route("organize_documents", draft), + {"documents": details, "main_document_id": lead.pk}, + content_type="application/json", + ) + assert response.status_code == 200 + assert parse_qs(urlsplit(response.json()["redirect_url"]).query)["draft"] == [str(draft.pk)] + draft.refresh_from_db() + assert (draft.filing_type_code, draft.filing_type_name) == ("27959", "Complaint") + + # Starting another filing in the shared session cannot redirect submission. + other = FilingDraft.objects.create(user=draft.user, jurisdiction="illinois") + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = other.pk + session.save() + with patch("requests.post", return_value=FakeApiResponse(201, {"filing_id": "adoption-123"})): + response = client.post( + reverse("submit_final_filing") + f"?draft={draft.pk}", + {"confirm_submission": True, "efile_data": {"al_court_bundle": {}}}, + content_type="application/json", + ) + assert response.status_code == 200 + draft.refresh_from_db() + lead.refresh_from_db() + assert draft.status == FilingDraft.Status.SUBMITTED + assert (draft.filing_type_code, draft.filing_type_name) == (lead.filing_type_code, lead.filing_type_name) + assert client.session[CURRENT_DRAFT_SESSION_KEY] == other.pk + other.mark_submitted({"filing_id": "different-confirmation"}) + confirmation = client.get(response.json()["redirect_url"]) + assert confirmation.context["confirmation_number"] == "adoption-123" + assert client.post(route("filing_path", draft), {"existing_case": "new"}).status_code == 409 diff --git a/efile_app/efile/tests/test_filing_plan_actions.py b/efile_app/efile/tests/test_filing_plan_actions.py index 35503fb5..9f521f8d 100644 --- a/efile_app/efile/tests/test_filing_plan_actions.py +++ b/efile_app/efile/tests/test_filing_plan_actions.py @@ -401,7 +401,11 @@ def test_adding_a_document_on_the_way_back_from_review_goes_through_organizing(c {"documents_complete": "yes", "return_to": "review", "status_petition": "have"}, ) - assert response.url == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + "?return_to=review" + assert ( + response.url + == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + + f"?return_to=review&draft={signed_in.pk}" + ) @pytest.mark.django_db @@ -411,7 +415,7 @@ def test_a_complete_filing_returns_straight_to_review(client, signed_in): {"documents_complete": "yes", "return_to": "review", "status_petition": "have"}, ) - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) # --- The plan's own home ----------------------------------------------------- @@ -612,7 +616,7 @@ def test_a_case_the_plan_knows_is_not_searched_for_again(client, signed_in): response = client.get(reverse("case_lookup", kwargs={"jurisdiction": "illinois"})) assert response.status_code == 302 - assert response.url == reverse("case_confirmation", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_confirmation", kwargs={"jurisdiction": "illinois"}) @pytest.mark.django_db diff --git a/efile_app/efile/tests/test_navigation_menu.py b/efile_app/efile/tests/test_navigation_menu.py index f4455d46..0ea83775 100644 --- a/efile_app/efile/tests/test_navigation_menu.py +++ b/efile_app/efile/tests/test_navigation_menu.py @@ -81,7 +81,7 @@ def test_starting_a_filing_from_the_menu_knows_which_kind_it_is(client, user, ch draft = FilingDraft.objects.get(user=user) assert response.status_code == 302 - assert response.url == UPLOAD_URL + assert response.url == f"{UPLOAD_URL}?draft={draft.pk}" assert draft.existing_case == expected assert draft.current_step == WorkflowStepKey.UPLOAD_DOCUMENTS # The filing the filer just asked for is the one they are now in. @@ -94,7 +94,7 @@ def test_starting_a_filing_without_saying_which_kind_asks(client, user): response = client.post(START_URL, {}) - assert response.url == FILING_PATH_URL + assert response.url == f"{FILING_PATH_URL}?draft={FilingDraft.objects.get(user=user).pk}" assert FilingDraft.objects.get(user=user).existing_case == "" diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py index ad07662b..819af606 100644 --- a/efile_app/efile/tests/test_people_flow.py +++ b/efile_app/efile/tests/test_people_flow.py @@ -62,7 +62,7 @@ def test_your_information_persists_filer_contact(client, people_draft): people_draft.refresh_from_db() filer = people_draft.parties.get(role="filer") assert response.status_code == 302 - assert response.url == reverse("parties", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("parties", kwargs={"jurisdiction": "illinois"}) assert filer.first_name == "Jamie" assert filer.address_line_1 == "100 State Street" assert people_draft.current_step == WorkflowStepKey.PARTIES @@ -97,7 +97,7 @@ def test_your_information_returns_to_review_when_edited_from_there(client, peopl people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) assert people_draft.current_step == WorkflowStepKey.REVIEW @@ -182,7 +182,7 @@ def test_parties_creates_missing_required_party_and_repeats_details(client, peop filer.refresh_from_db() other = people_draft.parties.get(role="other") assert response.status_code == 302 - assert response.url.endswith(f"?party={other.pk}") + assert response.url.endswith(f"?party={other.pk}&draft={people_draft.pk}") assert filer.party_type == "plaintiff" assert other.party_type == "defendant" assert other.party_type_name == "Defendant" @@ -224,7 +224,7 @@ def test_parties_returns_to_review_when_edited_from_there(client, people_draft): people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) assert people_draft.current_step == WorkflowStepKey.REVIEW @@ -282,7 +282,7 @@ def test_party_details_saves_party_and_advances_to_payment_when_no_questions(cli people_draft.refresh_from_db() party.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("payment", kwargs={"jurisdiction": "illinois"}) assert party.first_name == "Morgan" assert people_draft.current_step == WorkflowStepKey.PAYMENT @@ -322,7 +322,7 @@ def test_party_details_saves_an_other_party_without_an_optional_address(client, party.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("payment", kwargs={"jurisdiction": "illinois"}) assert party.first_name == "Morgan" assert party.address_line_1 == "" @@ -465,7 +465,7 @@ def test_party_details_returns_to_review_when_edited_from_there(client, people_d people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) assert people_draft.current_step == WorkflowStepKey.REVIEW @@ -482,7 +482,7 @@ def test_case_questions_are_configured_and_saved(client, people_draft): people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("payment", kwargs={"jurisdiction": "illinois"}) assert people_draft.supplemental_fields["has_children"] is True assert people_draft.supplemental_fields["child_count"] == 2 assert people_draft.supplemental_fields["_case_questions_required"] is True @@ -501,7 +501,7 @@ def test_case_questions_returns_to_review_when_edited_from_there(client, people_ people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) assert people_draft.current_step == WorkflowStepKey.REVIEW @@ -543,7 +543,7 @@ def test_case_questions_saves_a_valid_amount_in_controversy(client, people_draft people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("payment", kwargs={"jurisdiction": "illinois"}) assert people_draft.amount_in_controversy == "12500.00" @@ -614,7 +614,7 @@ def test_parties_routes_to_case_questions_when_amount_in_controversy_is_needed(c people_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("case_questions", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_questions", kwargs={"jurisdiction": "illinois"}) assert people_draft.current_step == WorkflowStepKey.CASE_QUESTIONS diff --git a/efile_app/efile/tests/test_reorganized_start.py b/efile_app/efile/tests/test_reorganized_start.py index a0d441d3..2b9a433a 100644 --- a/efile_app/efile/tests/test_reorganized_start.py +++ b/efile_app/efile/tests/test_reorganized_start.py @@ -36,7 +36,7 @@ def test_filing_path_saves_normalized_branch(client, reorganized_draft): reorganized_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) assert reorganized_draft.existing_case == ExistingCase.EXISTING assert reorganized_draft.current_step == WorkflowStepKey.UPLOAD_DOCUMENTS @@ -131,7 +131,7 @@ def test_extraction_review_branches_new_case_to_checklist(client, reorganized_dr reorganized_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) assert reorganized_draft.existing_case == ExistingCase.NEW assert reorganized_draft.court_code == "cook" assert reorganized_draft.case_type_code == "NC" @@ -163,7 +163,7 @@ def test_extraction_review_returns_to_review_when_edited_from_there(client, reor reorganized_draft.refresh_from_db() assert response.status_code == 302 - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) assert reorganized_draft.current_step == WorkflowStepKey.REVIEW diff --git a/efile_app/efile/tests/test_review_submit_flow.py b/efile_app/efile/tests/test_review_submit_flow.py index ef9e99fd..89145f2c 100644 --- a/efile_app/efile/tests/test_review_submit_flow.py +++ b/efile_app/efile/tests/test_review_submit_flow.py @@ -66,7 +66,7 @@ def test_payment_saves_account_and_advances_durable_step(client, submission_draf ) assert response.status_code == 302 - assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert response.url.partition("?")[0] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) submission_draft.refresh_from_db() assert submission_draft.selected_payment_account_id == "pay-123" assert submission_draft.current_step == WorkflowStepKey.REVIEW @@ -246,8 +246,8 @@ def test_review_uses_new_edit_routes_and_durable_summary(client, submission_draf assert b"review-document-tag" in response.content assert reverse("organize_documents", kwargs={"jurisdiction": "illinois"}).encode() in response.content assert reverse("your_information", kwargs={"jurisdiction": "illinois"}).encode() in response.content - assert reverse("expert_form", kwargs={"jurisdiction": "illinois"}).encode() not in response.content - assert reverse("upload", kwargs={"jurisdiction": "illinois"}).encode() not in response.content + assert f'href="{reverse("expert_form", kwargs={"jurisdiction": "illinois"})}'.encode() not in response.content + assert f'href="{reverse("upload", kwargs={"jurisdiction": "illinois"})}'.encode() not in response.content @pytest.mark.django_db diff --git a/efile_app/efile/views/confirmation.py b/efile_app/efile/views/confirmation.py index a29e15f9..a9932c62 100644 --- a/efile_app/efile/views/confirmation.py +++ b/efile_app/efile/views/confirmation.py @@ -3,6 +3,7 @@ from efile.api.suffolk_api_views import get_tyler_token from efile.models import FilingDraft +from efile.services.current_drafts import resolve_explicit_draft from ..workflow import WorkflowStepKey, get_workflow_context @@ -36,13 +37,16 @@ def filing_confirmation(request, jurisdiction): jurisdiction=jurisdiction, status=FilingDraft.Status.SUBMITTED, ) + draft = resolve_explicit_draft(request, jurisdiction=jurisdiction, statuses=(FilingDraft.Status.SUBMITTED,)) draft_id = request.session.get(LAST_SUBMITTED_DRAFT_SESSION_KEY) - draft = submitted.filter(pk=draft_id).first() if draft_id else None + if draft is None: + draft = submitted.filter(pk=draft_id).first() if draft_id else None if draft is None: draft = submitted.order_by("-submitted_at", "-updated_at").first() if draft is None: messages.info(request, "No submitted filing was found for this confirmation page.") return redirect("filing_statuses", jurisdiction=jurisdiction) + request.filing_draft = draft context = { "is_logged_in": True, "page_title": "Filing confirmation", diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 8efc1d4b..6ea24783 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -10,7 +10,7 @@ from efile.services.document_extractions import extraction_for_document from efile.services.drafts import draft_snapshot, write_case_data from efile.services.extracted_parties import review_rows, save_reviewed_parties -from efile.services.extraction_fields import document_summary_details, supporting_details +from efile.services.extraction_fields import display_extracted_fields, document_summary_details, supporting_details from efile.utils.ui_text import get_text from efile.workflow import ( RETURN_TO_REVIEW, @@ -169,7 +169,7 @@ def extraction_review(request, jurisdiction): write_case_data(draft, {}, current_step=next_step.key) return redirect(get_step_url(next_step.key, jurisdiction)) - guesses = draft.extracted_guesses or {} + guesses = display_extracted_fields(draft.extracted_guesses or {}) classification = extraction.classification if extraction is not None else {} def classified(level, key): diff --git a/efile_app/efile/views/options.py b/efile_app/efile/views/options.py index 417edd26..42dc3f46 100644 --- a/efile_app/efile/views/options.py +++ b/efile_app/efile/views/options.py @@ -18,7 +18,7 @@ def efile_options(request, jurisdiction): # Get case data from session if request.user.is_authenticated: case_data = get_case_data(request, jurisdiction) - active_draft = get_current_draft(request, jurisdiction=jurisdiction) + active_draft = get_current_draft(request, jurisdiction=jurisdiction, resume_latest=True) plans = plans_for(request.user, jurisdiction) draft_count = active_drafts_for(request.user, jurisdiction=jurisdiction).count() else: diff --git a/efile_app/playwright.draft-scope.config.js b/efile_app/playwright.draft-scope.config.js new file mode 100644 index 00000000..a5e53658 --- /dev/null +++ b/efile_app/playwright.draft-scope.config.js @@ -0,0 +1,14 @@ +// Browser-only transport checks; no EFSP account or live server is needed. +const { + defineConfig +} = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests', + testMatch: 'draft-scope.spec.js', + timeout: 30000, + use: { + browserName: 'chromium', + headless: true + }, +}); \ No newline at end of file diff --git a/efile_app/tests/accessibility.spec.js b/efile_app/tests/accessibility.spec.js index fb204719..1170fcbb 100644 --- a/efile_app/tests/accessibility.spec.js +++ b/efile_app/tests/accessibility.spec.js @@ -53,8 +53,11 @@ async function audit(page, label, scope) { } } +// The workflow screens rewrite their own URL to name the draft they are on +// (see static/js/draft-scope.js), so match the path and ignore any query or +// fragment. This still catches a screen that redirects somewhere else. function routePattern(url) { - return new RegExp(`${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/?$`); + return new RegExp(`${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/?(?:[?#].*)?$`); } for (const [label, url] of publicScreens) { diff --git a/efile_app/tests/draft-scope.spec.js b/efile_app/tests/draft-scope.spec.js new file mode 100644 index 00000000..09e0cb7a --- /dev/null +++ b/efile_app/tests/draft-scope.spec.js @@ -0,0 +1,111 @@ +const { + test, + expect +} = require('@playwright/test'); +const fs = require('node:fs'); +const path = require('node:path'); + +const script = fs.readFileSync(path.join(__dirname, '../efile/static/js/draft-scope.js'), 'utf8'); +const base = 'http://draft-scope.test'; +const uploadPath = '/jurisdiction/illinois/upload-documents/'; +const reviewPath = '/jurisdiction/illinois/review/'; + +async function serveWorkflow(context) { + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.pathname.startsWith('/api/')) { + await route.fulfill({ + json: { + draft: url.searchParams.get('draft') || route.request().headers()['x-filing-draft'], + body: route.request().postData(), + } + }); + return; + } + const id = Number(url.searchParams.get('draft') || url.searchParams.get('fixture')); + await route.fulfill({ + contentType: 'text/html', + body: ` + <!doctype html><html><head> + <script type="application/json" id="draft-scope">${JSON.stringify({id, paths: [uploadPath, reviewPath]})}</script> + <script>${script}</script> + </head><body> + <a id="next" href="${reviewPath}?return_to=review#details">Next</a> + <a id="resume" href="${uploadPath}?draft=3">Resume a third filing</a> + <form id="save" method="post" action="${reviewPath}"><button>Save</button></form> + <form id="search" method="get" action="${uploadPath}"><button>Search</button></form> + <form id="start" method="post" action="/jurisdiction/illinois/start-filing/"><button>Start</button></form> + <div id="dynamic"></div> + </body></html> + ` + }); + }); +} + +for (const sharedSession of [false, true]) { + test(`draft identity survives concurrent ${sharedSession ? 'tabs' : 'browser contexts'}`, async ({ + browser + }) => { + const firstContext = await browser.newContext(); + const secondContext = sharedSession ? firstContext : await browser.newContext(); + try { + await serveWorkflow(firstContext); + if (!sharedSession) await serveWorkflow(secondContext); + const first = await firstContext.newPage(); + const second = await secondContext.newPage(); + await Promise.all([ + first.goto(base + uploadPath + '?fixture=1'), + second.goto(base + uploadPath + '?fixture=2'), + ]); + for (const [page, id] of [ + [first, '1'], + [second, '2'] + ]) { + expect(new URL(page.url()).searchParams.get('draft')).toBe(id); + await expect(page.locator('#next')).toHaveAttribute('href', base + reviewPath + '?return_to=review&draft=' + id + '#details'); + await expect(page.locator('#resume')).toHaveAttribute('href', base + uploadPath + '?draft=3'); + await expect(page.locator('#save input[name="draft"]')).toHaveValue(id); + await expect(page.locator('#search input[name="draft"]')).toHaveValue(id); + await expect(page.locator('#start')).toHaveAttribute('action', '/jurisdiction/illinois/start-filing/'); + await expect(page.locator('#start input[name="draft"]')).toHaveCount(0); + const result = await page.evaluate(async () => { + const response = await fetch(new Request(location.origin + '/api/save-case-data/', { + method: 'POST', + body: JSON.stringify({ + case_title: 'My filing' + }), + })); + return response.json(); + }); + expect(result).toEqual({ + draft: id, + body: JSON.stringify({ + case_title: 'My filing' + }) + }); + const external = await page.evaluate(() => window.withFilingDraft('https://court.example/api/upload', true)); + expect(external).toBe('https://court.example/api/upload'); + } + + // A resume action in the second page cannot change the first. + await second.locator('#resume').click(); + await first.locator('#next').click(); + expect(new URL(first.url()).searchParams.get('draft')).toBe('1'); + expect(new URL(second.url()).searchParams.get('draft')).toBe('3'); + await first.reload(); + expect(new URL(first.url()).searchParams.get('draft')).toBe('1'); + await first.evaluate(() => { + const link = document.createElement('a'); + link.id = 'inserted'; + link.href = '/jurisdiction/illinois/upload-documents/'; + document.getElementById('dynamic').append(link); + }); + await expect(first.locator('#inserted')).toHaveAttribute('href', base + uploadPath + '?draft=1'); + await first.locator('#save button').click(); + expect(new URL(first.url()).searchParams.get('draft')).toBe('1'); + } finally { + await firstContext.close(); + if (!sharedSession) await secondContext.close(); + } + }); +} \ No newline at end of file