feat(ops): add disabled action foundation and support report creation (#15)

* feat(ops): add truthful disabled action plane and support modal

* fix(ops): align support report types with backend contract

* fix(ops): address action audit and support validation review

* test(website): add behavioral coverage for support reports

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-09-21 01:19:47 -04:00 committed by GitHub
parent 7efd29a214
commit 0344691194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2926 additions and 11 deletions

View File

@ -42,4 +42,4 @@ jobs:
- name: Run isolated API and Operations unit tests - name: Run isolated API and Operations unit tests
run: | run: |
pytest tests/test_ops_support.py tests/test_ops_incidents.py tests/test_legacy_admin_retirement.py -v pytest tests/test_ops_support.py tests/test_ops_incidents.py tests/test_legacy_admin_retirement.py tests/test_ops_actions.py -v

View File

@ -40,6 +40,9 @@ jobs:
MOTORSIGNAL_GIT_SHA: ${{ github.sha }} MOTORSIGNAL_GIT_SHA: ${{ github.sha }}
run: npm run build run: npm run build
- name: Run React component tests
run: npm run test:components
- name: Run Founder Portal tests - name: Run Founder Portal tests
run: node tests/founder-portal.test.cjs run: node tests/founder-portal.test.cjs

View File

@ -0,0 +1,50 @@
"""add ops actions
Revision ID: f1a2b3c4d5e7
Revises: f1a2b3c4d5e6
Create Date: 2026-09-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5e7'
down_revision = 'f1a2b3c4d5e6'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'ops_actions',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('action_type', sa.String(255), nullable=False),
sa.Column('requested_by_id', UUID(as_uuid=True), sa.ForeignKey('ops.operators.id', ondelete='RESTRICT'), nullable=False),
sa.Column('request_timestamp', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('start_timestamp', sa.DateTime(timezone=True), nullable=True),
sa.Column('completion_timestamp', sa.DateTime(timezone=True), nullable=True),
sa.Column('state', sa.String(50), server_default='queued', nullable=False),
sa.Column('status_message', sa.Text(), nullable=True),
sa.Column('deployed_revision', sa.String(255), nullable=True),
sa.Column('target_revision', sa.String(255), nullable=True),
sa.Column('preflight_result', sa.Text(), nullable=True),
sa.Column('health_result', sa.Text(), nullable=True),
sa.Column('rollback_status', sa.Text(), nullable=True),
sa.Column('failure_code', sa.String(255), nullable=True),
sa.Column('idempotency_key', sa.String(255), nullable=True),
schema='ops'
)
# Ensure idempotency_key is unique to prevent duplicate processing
op.create_index(
'ix_ops_actions_idempotency_key',
'ops_actions',
['requested_by_id', 'action_type', 'idempotency_key'],
unique=True,
schema='ops',
postgresql_where=sa.text('idempotency_key IS NOT NULL')
)
def downgrade() -> None:
op.drop_index('ix_ops_actions_idempotency_key', table_name='ops_actions', schema='ops')
op.drop_table('ops_actions', schema='ops')

View File

@ -10,7 +10,7 @@ from typing import Literal
from email_validator import EmailNotValidError, validate_email from email_validator import EmailNotValidError, validate_email
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import func, or_, select, text from sqlalchemy import func, or_, select, text
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
@ -29,6 +29,7 @@ from auth import (
) )
from db import check_database, engine from db import check_database, engine
from models import ( from models import (
OpsAction,
AuditEvent, AuditEvent,
AuthRateLimit, AuthRateLimit,
EmailVerificationToken, EmailVerificationToken,
@ -1078,6 +1079,30 @@ class OpsSupportDiagnosticBundle(BaseModel):
submitted_at: str submitted_at: str
class OpsActionCreate(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
action_type: Literal["deploy_website", "restart_website", "restart_api", "reboot_server"]
idempotency_key: str | None = Field(default=None, min_length=8, max_length=128)
confirmation_text: str | None = Field(default=None, max_length=64)
class OpsActionRead(BaseModel):
id: uuid.UUID
action_type: str
requested_by_id: uuid.UUID
request_timestamp: datetime
start_timestamp: datetime | None
completion_timestamp: datetime | None
state: str
status_message: str | None
deployed_revision: str | None
target_revision: str | None
preflight_result: str | None
health_result: str | None
rollback_status: str | None
failure_code: str | None
idempotency_key: str | None
class OpsSupportTicketCreate(BaseModel): class OpsSupportTicketCreate(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
ticket_type: str = Field(default="general") ticket_type: str = Field(default="general")
@ -1087,6 +1112,22 @@ class OpsSupportTicketCreate(BaseModel):
severity: str = Field(default="medium") severity: str = Field(default="medium")
diagnostic_bundle: OpsSupportDiagnosticBundle | dict | None = None diagnostic_bundle: OpsSupportDiagnosticBundle | dict | None = None
@field_validator("reporter_email")
@classmethod
def validate_reporter_email(cls, v: str) -> str:
s = v.strip()
if len(s) < 3:
raise ValueError("Reporter email must not be empty or whitespace only.")
return s
@field_validator("subject", "description")
@classmethod
def validate_non_whitespace(cls, v: str) -> str:
s = v.strip()
if not s:
raise ValueError("Field cannot be empty or contain only whitespace.")
return s
class OpsSupportTicketPatch(BaseModel): class OpsSupportTicketPatch(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -2425,6 +2466,54 @@ def ops_get_support_ticket(
} }
@app.post("/api/v1/ops/actions", response_model=OpsActionRead)
def ops_create_action(
req: OpsActionCreate,
db: DbSession = Depends(get_db),
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")),
):
operator, session = auth
if req.action_type in ("reboot_server", "restart_api"):
expected = "REBOOT MOTORSIGNAL SERVER" if req.action_type == "reboot_server" else "RESTART API"
if req.confirmation_text != expected:
raise HTTPException(status_code=422, detail="Invalid confirmation text.")
# We explicitly implement a disabled control plane per the instructions
# Check control-plane availability before performing any idempotency lookup.
audit = OpsAuditEvent(
action="action_rejected",
actor_identity=operator.identity,
actor_role=operator.role,
target_resource="unavailable_control_plane",
result="denied",
metadata_json={"action_type": req.action_type, "status": "rejected_unavailable"},
)
db.add(audit)
db.commit()
raise HTTPException(status_code=503, detail="Action control plane is currently unconfigured. Missing ops-action-helper.sh.")
@app.get("/api/v1/ops/actions", response_model=list[OpsActionRead])
def ops_list_actions(
db: DbSession = Depends(get_db),
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
return db.scalars(select(OpsAction).order_by(OpsAction.request_timestamp.desc(), OpsAction.id.desc()).offset(offset).limit(limit)).all()
@app.get("/api/v1/ops/actions/{action_id}", response_model=OpsActionRead)
def ops_get_action(
action_id: uuid.UUID,
db: DbSession = Depends(get_db),
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
):
action = db.get(OpsAction, action_id)
if not action:
raise HTTPException(status_code=404, detail="Action not found")
return action
@app.post("/api/v1/ops/support/tickets", status_code=201) @app.post("/api/v1/ops/support/tickets", status_code=201)
def ops_create_support_ticket( def ops_create_support_ticket(
request: OpsSupportTicketCreate, request: OpsSupportTicketCreate,

View File

@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String from sqlalchemy import JSON, Index, Boolean, DateTime, ForeignKey, String, func, Text, text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@ -378,3 +378,31 @@ class OpsIncidentNote(Base):
body: Mapped[str] = mapped_column(String, nullable=False) body: Mapped[str] = mapped_column(String, nullable=False)
incident: Mapped["OpsIncident"] = relationship(back_populates="notes") incident: Mapped["OpsIncident"] = relationship(back_populates="notes")
class OpsAction(Base):
__tablename__ = "ops_actions"
__table_args__ = (
Index(
"ix_ops_actions_idempotency_key",
"requested_by_id", "action_type", "idempotency_key",
unique=True,
postgresql_where=text("idempotency_key IS NOT NULL"),
),
{"schema": "ops"},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
action_type: Mapped[str] = mapped_column(String(255), nullable=False)
requested_by_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False)
request_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
start_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completion_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
state: Mapped[str] = mapped_column(String(50), server_default="queued", nullable=False)
status_message: Mapped[str | None] = mapped_column(Text, nullable=True)
deployed_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
target_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
preflight_result: Mapped[str | None] = mapped_column(Text, nullable=True)
health_result: Mapped[str | None] = mapped_column(Text, nullable=True)
rollback_status: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_code: Mapped[str | None] = mapped_column(String(255), nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True)

View File

@ -0,0 +1,296 @@
import hashlib
import os
import sys
import unittest
import uuid
from datetime import datetime, timedelta, timezone
from time import sleep
ROOT = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
sys.path.insert(0, os.path.join(ROOT, "app"))
os.environ.setdefault("JWT_SECRET", "local-test-secret")
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
os.environ["DATABASE_USER"] = "test"
os.environ["DATABASE_PASSWORD"] = "test"
os.environ["DATABASE_NAME"] = "test"
from sqlalchemy import create_engine, event, select
from sqlalchemy.orm import Session as DbSession
from starlette.testclient import TestClient
import main
from main import app, get_db
from models import Base, Operator, OpsAuditEvent, OpsSession, OpsAction
def hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode()).hexdigest()
class TestOpsActions(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.db_path = os.path.join(ROOT, "tests", "test_ops_actions.db")
cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_actions_schema.db")
for p in (cls.db_path, cls.ops_db_path):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
cls.engine = create_engine(f"sqlite:///{cls.db_path}")
@event.listens_for(cls.engine, "connect")
def attach_ops(dbapi_con, con_record):
dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops")
Base.metadata.create_all(cls.engine)
def override_get_db():
db = DbSession(cls.engine)
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
cls.client = TestClient(app, raise_server_exceptions=False)
@classmethod
def tearDownClass(cls):
app.dependency_overrides.clear()
cls.engine.dispose()
for p in (cls.db_path, cls.ops_db_path):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
def setUp(self):
with DbSession(self.engine) as db:
db.query(OpsAuditEvent).delete()
db.query(OpsAction).delete()
db.query(OpsSession).delete()
db.query(Operator).delete()
db.commit()
self.op_id = uuid.uuid4()
self.op_identity = "owner@motorsignal.internal"
self.op_token = "valid-owner-token-123"
op = Operator(
id=self.op_id,
identity=self.op_identity,
role="owner",
status="active",
created_at=datetime.now(timezone.utc),
)
op_session = OpsSession(
operator_id=self.op_id,
session_token_hash=hash_token(self.op_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
created_at=datetime.now(timezone.utc),
)
self.expired_owner_token = "expired-owner-token-123"
op_session_expired = OpsSession(
operator_id=self.op_id,
session_token_hash=hash_token(self.expired_owner_token),
expires_at=datetime.now(timezone.utc) - timedelta(hours=8),
created_at=datetime.now(timezone.utc) - timedelta(hours=16),
)
self.operator_id = uuid.uuid4()
self.operator_identity = "operator@motorsignal.internal"
self.operator_token = "valid-operator-token-123"
op_reg = Operator(
id=self.operator_id,
identity=self.operator_identity,
role="operator",
status="active",
created_at=datetime.now(timezone.utc),
)
op_session_reg = OpsSession(
operator_id=self.operator_id,
session_token_hash=hash_token(self.operator_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
created_at=datetime.now(timezone.utc),
)
db.add(op)
db.add(op_session)
db.add(op_session_expired)
db.add(op_reg)
db.add(op_session_reg)
db.commit()
self.owner_headers = {"Authorization": f"Bearer {self.op_token}"}
self.expired_owner_headers = {"Authorization": f"Bearer {self.expired_owner_token}"}
self.operator_headers = {"Authorization": f"Bearer {self.operator_token}"}
def test_unauthenticated_rejection(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"})
self.assertEqual(resp.status_code, 401)
def test_expired_session_rejection(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.expired_owner_headers)
self.assertEqual(resp.status_code, 401)
def test_operator_rejection(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.operator_headers)
self.assertEqual(resp.status_code, 403)
def test_owner_access(self):
resp = self.client.get("/api/v1/ops/actions", headers=self.owner_headers)
self.assertEqual(resp.status_code, 200)
def test_unknown_action_rejection(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "invalid_type"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
def test_unexpected_field_rejection(self):
resp = self.client.post(
"/api/v1/ops/actions",
json={"action_type": "deploy_website", "extra_field": "test"},
headers=self.owner_headers
)
self.assertEqual(resp.status_code, 422)
def test_idempotency_key_bounds(self):
# Too short
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": "1234567"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
# Too long
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": "a" * 129}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
def test_confirmation_text_bounds(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api", "confirmation_text": "a" * 65}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
def test_exact_api_restart_confirmation(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api", "confirmation_text": "RESTART API"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 503)
def test_exact_reboot_confirmation(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "reboot_server"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "reboot_server", "confirmation_text": "REBOOT MOTORSIGNAL SERVER"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 503)
def test_unavailable_control_plane_all_actions(self):
actions_with_confirm = [
("deploy_website", None),
("restart_website", None),
("restart_api", "RESTART API"),
("reboot_server", "REBOOT MOTORSIGNAL SERVER"),
]
for act, conf in actions_with_confirm:
with DbSession(self.engine) as db:
db.query(OpsAuditEvent).delete()
db.commit()
payload = {"action_type": act}
if conf:
payload["confirmation_text"] = conf
resp = self.client.post("/api/v1/ops/actions", json=payload, headers=self.owner_headers)
self.assertEqual(resp.status_code, 503)
self.assertIn("Missing ops-action-helper.sh", resp.json()["detail"])
# Verify no OpsAction row is created
with DbSession(self.engine) as db:
count = db.scalar(select(main.func.count(OpsAction.id)))
self.assertEqual(count, 0)
# Verify audit event is created with result="denied" (never successful)
with DbSession(self.engine) as db:
audit = db.query(OpsAuditEvent).filter_by(action="action_rejected").first()
self.assertIsNotNone(audit)
self.assertEqual(audit.actor_role, "owner")
self.assertEqual(audit.actor_identity, self.op_identity)
self.assertEqual(audit.result, "denied")
self.assertNotEqual(audit.result, "success")
self.assertEqual(audit.metadata_json.get("action_type"), act)
# Ensure no sensitive confirmation text/idempotency keys in audit
self.assertNotIn("idempotency_key", audit.metadata_json)
self.assertNotIn("confirmation_text", audit.metadata_json)
def test_idempotent_no_action_created(self):
"""
Documenting that while the control plane is disabled, unavailable requests
create audit events but DO NOT create an idempotent action result.
Full idempotent action replay belongs to the configured executor milestone.
"""
key = "idemp-key-test-1"
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": key}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 503)
# Another request with the same key
resp2 = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": key}, headers=self.owner_headers)
self.assertEqual(resp2.status_code, 503)
with DbSession(self.engine) as db:
count = db.scalar(select(main.func.count(OpsAction.id)))
self.assertEqual(count, 0)
audits = db.query(OpsAuditEvent).filter_by(action="action_rejected").all()
self.assertGreaterEqual(len(audits), 2)
for a in audits:
self.assertEqual(a.result, "denied")
self.assertNotEqual(a.result, "success")
def test_action_detail_404(self):
resp = self.client.get(f"/api/v1/ops/actions/{uuid.uuid4()}", headers=self.owner_headers)
self.assertEqual(resp.status_code, 404)
def test_sanitized_response(self):
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.owner_headers)
self.assertEqual(resp.status_code, 503)
self.assertNotIn("var", resp.json()["detail"])
self.assertNotIn("opt", resp.json()["detail"])
def test_pagination_bounds(self):
resp = self.client.get("/api/v1/ops/actions?limit=0", headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
resp = self.client.get("/api/v1/ops/actions?limit=101", headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
resp = self.client.get("/api/v1/ops/actions?offset=-1", headers=self.owner_headers)
self.assertEqual(resp.status_code, 422)
def test_deterministic_action_listing_ordering(self):
with DbSession(self.engine) as db:
t1 = datetime.now(timezone.utc)
t2 = t1 + timedelta(seconds=1)
id1 = uuid.uuid4()
id2 = uuid.uuid4()
id3 = uuid.uuid4()
# Action 1 and 2 have identical timestamp. Ordering must resolve by id DESC
db.add(OpsAction(id=id1, action_type="deploy_website", requested_by_id=self.op_id, state="queued", request_timestamp=t1))
db.add(OpsAction(id=id2, action_type="restart_website", requested_by_id=self.op_id, state="queued", request_timestamp=t1))
# Action 3 is later
db.add(OpsAction(id=id3, action_type="restart_api", requested_by_id=self.op_id, state="queued", request_timestamp=t2))
db.commit()
resp = self.client.get("/api/v1/ops/actions?limit=5", headers=self.owner_headers)
self.assertEqual(resp.status_code, 200)
data = resp.json()
# Most recent first
self.assertEqual(data[0]["id"], str(id3))
# Then stable secondary sort by id DESC
if str(id1) > str(id2):
self.assertEqual(data[1]["id"], str(id1))
self.assertEqual(data[2]["id"], str(id2))
else:
self.assertEqual(data[1]["id"], str(id2))
self.assertEqual(data[2]["id"], str(id1))
if __name__ == '__main__':
unittest.main()

View File

@ -271,6 +271,70 @@ class TestOpsSupport(unittest.TestCase):
) )
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
def test_create_ticket_rejects_whitespace_only_subject(self):
payload = {
"ticket_type": "bug_report",
"reporter_email": "testrider@example.com",
"subject": " ",
"description": "Valid description for the ticket.",
"severity": "medium",
}
resp = self.client.post(
"/api/v1/ops/support/tickets",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 422)
def test_create_ticket_rejects_whitespace_only_description(self):
payload = {
"ticket_type": "bug_report",
"reporter_email": "testrider@example.com",
"subject": "Valid subject",
"description": " \t \n ",
"severity": "medium",
}
resp = self.client.post(
"/api/v1/ops/support/tickets",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 422)
def test_create_ticket_rejects_whitespace_only_reporter_email(self):
payload = {
"ticket_type": "bug_report",
"reporter_email": " ",
"subject": "Valid subject",
"description": "Valid description",
"severity": "medium",
}
resp = self.client.post(
"/api/v1/ops/support/tickets",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 422)
def test_create_ticket_trims_surrounding_whitespace(self):
payload = {
"ticket_type": "bug_report",
"reporter_email": " testrider@example.com ",
"subject": " Bluetooth disconnects after 10 mins ",
"description": " App drops connection to OBD reader after 10 minutes. ",
"severity": "medium",
}
resp = self.client.post(
"/api/v1/ops/support/tickets",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["reporter_email"], "testrider@example.com")
self.assertEqual(data["subject"], "Bluetooth disconnects after 10 mins")
self.assertEqual(data["description"], "App drops connection to OBD reader after 10 minutes.")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -10,6 +10,7 @@ import {
SupportTicketStatus, SupportTicketStatus,
SupportDiagnosticBundle, SupportDiagnosticBundle,
} from './types'; } from './types';
import { SupportReportModal } from './support-report-modal';
interface OperationsSupportProps { interface OperationsSupportProps {
role: OperationsRole; role: OperationsRole;
@ -22,6 +23,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
const [totalCount, setTotalCount] = useState<number | null>(null); const [totalCount, setTotalCount] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Tab & Search filters // Tab & Search filters
@ -800,6 +802,14 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
// Otherwise, render Ticket Inbox (List view) // Otherwise, render Ticket Inbox (List view)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SupportReportModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={(ticket: SupportTicketSummary) => {
fetchTickets(true);
handleSelectTicket(ticket.id);
}}
/>
{/* Top Title Bar */} {/* Top Title Bar */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30"> <div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
<div> <div>
@ -815,9 +825,18 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
</div> </div>
{/* Manual Refresh Only */} {/* Manual Refresh Only */}
<button <div className="flex items-center gap-2">
type="button" <button
onClick={() => fetchTickets(true)} type="button"
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-primary/50 bg-primary/10 hover:bg-primary/20 text-xs sm:text-sm font-semibold text-primary transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-[18px]">add</span>
New Support Report
</button>
<button
type="button"
onClick={() => fetchTickets(true)}
disabled={isRefreshing || isLoading} disabled={isRefreshing || isLoading}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high text-xs sm:text-sm font-semibold text-on-surface transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none self-start sm:self-auto shrink-0 shadow-sm" className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high text-xs sm:text-sm font-semibold text-on-surface transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none self-start sm:self-auto shrink-0 shadow-sm"
> >
@ -830,6 +849,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
</span> </span>
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span> <span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
</button> </button>
</div>
</div> </div>
{/* Filter Tabs & Search Bar */} {/* Filter Tabs & Search Bar */}
@ -980,7 +1000,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
Bundle Bundle
</span> </span>
) : ( ) : (
<span className="text-outline font-mono text-xs">—</span> <span className="text-outline font-mono text-xs">—</span>
)} )}
</td> </td>
<td className="py-4 px-5 sm:px-6 font-mono text-outline">{t.notes_count ?? 0}</td> <td className="py-4 px-5 sm:px-6 font-mono text-outline">{t.notes_count ?? 0}</td>

View File

@ -0,0 +1,220 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { SupportTicketSummary } from './types';
interface SupportReportModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (ticket: SupportTicketSummary) => void;
}
export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReportModalProps) {
const [ticketType, setTicketType] = useState('general');
const [reporterEmail, setReporterEmail] = useState('');
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [severity, setSeverity] = useState('medium');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setTicketType('general');
setReporterEmail('');
setSubject('');
setDescription('');
setSeverity('medium');
setError(null);
// Focus first input after render
setTimeout(() => firstInputRef.current?.focus(), 100);
}
}, [isOpen]);
// Keyboard accessibility
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
const trimmedEmail = reporterEmail.trim();
const trimmedSubject = subject.trim();
const trimmedDescription = description.trim();
if (!trimmedSubject) {
setError('Subject cannot be empty or contain only whitespace.');
return;
}
if (!trimmedDescription) {
setError('Description cannot be empty or contain only whitespace.');
return;
}
setIsSubmitting(true);
setError(null);
try {
const resp = await fetch('/api/ops/support/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ticket_type: ticketType,
reporter_email: trimmedEmail,
subject: trimmedSubject,
description: trimmedDescription,
severity,
})
});
const data = await resp.json().catch(() => null);
if (resp.ok) {
onSuccess(data);
onClose();
} else {
setError(data?.detail || 'Failed to submit report');
}
} catch {
setError('Network error during submission');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div
ref={modalRef}
className="bg-surface-container-high border border-outline-variant/30 w-full max-w-lg rounded-2xl shadow-xl overflow-hidden flex flex-col"
>
<div className="px-6 py-4 border-b border-outline-variant/20 flex items-center justify-between">
<h2 id="modal-title" className="text-lg font-bold text-on-surface">New Support Report</h2>
<button
type="button"
onClick={onClose}
className="p-1 rounded-full text-outline hover:bg-surface-container-highest hover:text-on-surface transition-colors"
aria-label="Close"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-4">
{error && (
<div className="p-3 text-sm bg-error/10 text-error border border-error/20 rounded-lg">
{error}
</div>
)}
<div className="space-y-1.5">
<label htmlFor="reporter_email" className="block text-sm font-medium text-on-surface">Reporter Email</label>
<input
id="reporter_email"
ref={firstInputRef}
type="email"
required
minLength={3}
maxLength={255}
value={reporterEmail}
onChange={e => setReporterEmail(e.target.value)}
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="ticket_type" className="block text-sm font-medium text-on-surface">Ticket Type</label>
<select
id="ticket_type"
value={ticketType}
onChange={e => setTicketType(e.target.value)}
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="general">General</option>
<option value="bug_report">Bug Report</option>
<option value="telemetry_issue">Telemetry Issue</option>
<option value="contact_request">Contact Request</option>
</select>
</div>
<div className="space-y-1.5">
<label htmlFor="severity" className="block text-sm font-medium text-on-surface">Severity</label>
<select
id="severity"
value={severity}
onChange={e => setSeverity(e.target.value)}
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div className="space-y-1.5">
<label htmlFor="subject" className="block text-sm font-medium text-on-surface">Subject</label>
<input
id="subject"
type="text"
required
minLength={1}
maxLength={255}
value={subject}
onChange={e => setSubject(e.target.value)}
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="Brief summary"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="description" className="block text-sm font-medium text-on-surface">Description</label>
<textarea
id="description"
required
minLength={1}
maxLength={10000}
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50 min-h-[120px]"
placeholder="Detailed description..."
/>
</div>
<div className="pt-4 flex items-center justify-end gap-3 border-t border-outline-variant/20">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-semibold text-on-surface-variant hover:text-on-surface transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-5 py-2 text-sm font-semibold bg-primary text-on-primary rounded-xl hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Create Ticket'}
</button>
</div>
</form>
</div>
</div>
);
}

1808
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"test:components": "vitest run"
}, },
"dependencies": { "dependencies": {
"firebase": "12.18.0", "firebase": "12.18.0",
@ -15,9 +16,13 @@
"react-dom": "19.1.1" "react-dom": "19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"typescript": "^5.9.0" "jsdom": "29.1.1",
"typescript": "^5.9.0",
"vitest": "5.0.1"
} }
} }

View File

@ -1082,6 +1082,32 @@ assert.ok(backendMainSrc.includes('support.ticket_updated'), 'Backend must audit
assert.ok(backendMainSrc.includes('support.diagnostics_viewed'), 'Backend must audit support.diagnostics_viewed'); assert.ok(backendMainSrc.includes('support.diagnostics_viewed'), 'Backend must audit support.diagnostics_viewed');
assert.ok(backendMainSrc.includes('SUP-'), 'Backend must generate SUP-YYYY-NNNNNN case references'); assert.ok(backendMainSrc.includes('SUP-'), 'Backend must generate SUP-YYYY-NNNNNN case references');
// 41.5 Support Report Modal static contract verification
// Note: Runtime interaction, form validation, duplicate submission protection,
// error handling, and success flows are behaviorally exercised in tests/support-report-modal.test.tsx.
const supportModalSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/support-report-modal.tsx'), 'utf8');
assert.ok(supportComponentSrc.includes('New Support Report'), 'Support list must provide New Support Report button');
assert.ok(supportModalSrc.includes('reporter_email'), 'Support modal must use exact backend field name reporter_email');
assert.ok(supportModalSrc.includes('ticket_type'), 'Support modal must use exact backend field name ticket_type');
assert.ok(supportModalSrc.includes('subject'), 'Support modal must use exact backend field name subject');
assert.ok(supportModalSrc.includes('description'), 'Support modal must use exact backend field name description');
assert.ok(supportModalSrc.includes('severity'), 'Support modal must use exact backend field name severity');
// Verify all four canonical ticket_type values are present and non-canonical values are absent
assert.ok(supportModalSrc.includes('value="general">General</option>'), 'Support modal must offer general option');
assert.ok(supportModalSrc.includes('value="bug_report">Bug Report</option>'), 'Support modal must offer bug_report option');
assert.ok(supportModalSrc.includes('value="telemetry_issue">Telemetry Issue</option>'), 'Support modal must offer telemetry_issue option');
assert.ok(supportModalSrc.includes('value="contact_request">Contact Request</option>'), 'Support modal must offer contact_request option');
assert.ok(!supportModalSrc.includes('billing'), 'Support modal must not offer billing option');
assert.ok(!supportModalSrc.includes('data_deletion'), 'Support modal must not offer data_deletion option');
// Accessible dialog semantics
assert.ok(supportModalSrc.includes('role="dialog"') && supportModalSrc.includes('aria-modal="true"'), 'Support modal must have dialog semantics aria tags');
// List refresh is verified by the inclusion of onSuccess callback in OperationsSupport
assert.ok(supportComponentSrc.includes('onSuccess={('), 'OperationsSupport must provide list refresh callback');
assert.ok(supportComponentSrc.includes('handleSelectTicket(ticket.id)'), 'OperationsSupport must provide selection/opening of the created ticket');
console.log(' PASS: Section 3 Support Center backend, proxy routes, navigation, and UI contracts fully verified.'); console.log(' PASS: Section 3 Support Center backend, proxy routes, navigation, and UI contracts fully verified.');
// ============================================================ // ============================================================

View File

@ -0,0 +1,301 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SupportReportModal } from '../components/ops/support-report-modal';
describe('SupportReportModal Component Tests', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
// ============================================================
// A. Visibility and lifecycle
// ============================================================
it('does not render dialog when isOpen=false, renders form when isOpen=true, and Escape calls onClose', async () => {
const onClose = vi.fn();
const onSuccess = vi.fn();
const { rerender } = render(
<SupportReportModal isOpen={false} onClose={onClose} onSuccess={onSuccess} />
);
expect(screen.queryByRole('dialog')).toBeNull();
rerender(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
const dialog = screen.getByRole('dialog');
expect(dialog).toBeDefined();
expect(screen.getByRole('heading', { name: /new support report/i })).toBeDefined();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
// ============================================================
// B. Canonical ticket types
// ============================================================
it('renders all four canonical ticket types and excludes deprecated billing or data_deletion', () => {
render(
<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />
);
const select = screen.getByLabelText(/ticket type/i) as HTMLSelectElement;
expect(select).toBeDefined();
const optionValues = Array.from(select.options).map(opt => opt.value);
const optionLabels = Array.from(select.options).map(opt => opt.textContent);
expect(optionValues).toEqual(['general', 'bug_report', 'telemetry_issue', 'contact_request']);
expect(optionLabels).toEqual(['General', 'Bug Report', 'Telemetry Issue', 'Contact Request']);
expect(optionValues).not.toContain('billing');
expect(optionValues).not.toContain('data_deletion');
});
// ============================================================
// C. Whitespace validation
// ============================================================
it('rejects whitespace-only subject without calling fetch and displays validation error', async () => {
const mockFetch = vi.fn();
global.fetch = mockFetch;
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: ' ' } });
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Valid description text' } });
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
expect(mockFetch).not.toHaveBeenCalled();
expect(screen.getByText('Subject cannot be empty or contain only whitespace.')).toBeDefined();
});
it('rejects whitespace-only description without calling fetch and displays validation error', async () => {
const mockFetch = vi.fn();
global.fetch = mockFetch;
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Valid subject title' } });
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: ' \n \t ' } });
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
expect(mockFetch).not.toHaveBeenCalled();
expect(screen.getByText('Description cannot be empty or contain only whitespace.')).toBeDefined();
});
// ============================================================
// D. Trimmed payload
// ============================================================
it('submits trimmed values and canonical ticket_type and severity to the backend API', async () => {
let capturedUrl = '';
let capturedOptions: RequestInit | undefined;
global.fetch = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
capturedUrl = url.toString();
capturedOptions = options;
return {
ok: true,
json: async () => ({
id: 'sup-12345',
case_reference: 'SUP-2026-000001',
ticket_type: 'telemetry_issue',
status: 'open',
severity: 'high',
reporter_email: 'driver@example.com',
subject: 'Speed sensor intermittent dropout',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
has_diagnostics: false,
notes_count: 0,
}),
} as Response;
});
const onSuccess = vi.fn();
const onClose = vi.fn();
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), {
target: { value: ' driver@example.com ' },
});
fireEvent.change(screen.getByLabelText(/ticket type/i), {
target: { value: 'telemetry_issue' },
});
fireEvent.change(screen.getByLabelText(/severity/i), {
target: { value: 'high' },
});
fireEvent.change(screen.getByLabelText(/subject/i), {
target: { value: ' Speed sensor intermittent dropout ' },
});
fireEvent.change(screen.getByLabelText(/description/i), {
target: { value: ' Telemetry packet dropped at 60mph on turn 4 ' },
});
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
expect(capturedUrl).toBe('/api/ops/support/tickets');
expect(capturedOptions?.method).toBe('POST');
expect(capturedOptions?.headers).toEqual({ 'Content-Type': 'application/json' });
const parsedBody = JSON.parse(capturedOptions?.body as string);
expect(parsedBody).toEqual({
ticket_type: 'telemetry_issue',
reporter_email: 'driver@example.com',
subject: 'Speed sensor intermittent dropout',
description: 'Telemetry packet dropped at 60mph on turn 4',
severity: 'high',
});
});
// ============================================================
// E. Duplicate-submit prevention
// ============================================================
it('prevents duplicate submissions while a request is pending and disables the submit button', async () => {
let resolveFetch!: (value: Response) => void;
const fetchPromise = new Promise<Response>(resolve => {
resolveFetch = resolve;
});
const mockFetch = vi.fn(() => fetchPromise);
global.fetch = mockFetch;
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'GPS sync failed' } });
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'GPS fix took 45 seconds.' } });
const submitBtn = screen.getByRole('button', { name: /create ticket/i }) as HTMLButtonElement;
fireEvent.click(submitBtn);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Button should now be disabled and show "Submitting..."
expect(submitBtn.disabled).toBe(true);
expect(submitBtn.textContent).toContain('Submitting...');
// Attempt second submission while first is still pending
fireEvent.click(submitBtn);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Resolve the pending fetch
resolveFetch({
ok: true,
json: async () => ({ id: 'ticket-999' }),
} as Response);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
// ============================================================
// F. Successful submission
// ============================================================
it('calls onSuccess with the returned ticket summary and closes modal on success', async () => {
const ticketSummary = {
id: 'ticket-abc-123',
case_reference: 'SUP-2026-000100',
ticket_type: 'general',
status: 'open',
severity: 'medium',
reporter_email: 'rider@example.com',
subject: 'Question on Bluetooth OBD link',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
has_diagnostics: false,
notes_count: 0,
};
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ticketSummary,
} as Response));
const onSuccess = vi.fn();
const onClose = vi.fn();
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'rider@example.com' } });
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Question on Bluetooth OBD link' } });
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Pairing instructions inquiry.' } });
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(ticketSummary));
expect(onClose).toHaveBeenCalledTimes(1);
});
// ============================================================
// G. API failure
// ============================================================
it('displays API error detail on non-2xx response, does not call onSuccess, and releases submitting state', async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 422,
json: async () => ({ detail: 'Invalid ticket type or email address' }),
} as Response));
const onSuccess = vi.fn();
const onClose = vi.fn();
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'rider@example.com' } });
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Test subject' } });
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Test description' } });
const submitBtn = screen.getByRole('button', { name: /create ticket/i }) as HTMLButtonElement;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText('Invalid ticket type or email address')).toBeDefined();
});
expect(onSuccess).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(submitBtn.disabled).toBe(false);
expect(submitBtn.textContent).toContain('Create Ticket');
});
// ============================================================
// H. Network failure
// ============================================================
it('displays safe user-facing error on network exception and does not leak internal error message', async () => {
global.fetch = vi.fn(async () => {
throw new Error('ECONNREFUSED 127.0.0.1:8080 internal crash');
});
const onSuccess = vi.fn();
const onClose = vi.fn();
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Network test' } });
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Network error test desc' } });
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
await waitFor(() => {
expect(screen.getByText('Network error during submission')).toBeDefined();
});
expect(screen.queryByText(/ECONNREFUSED/)).toBeNull();
expect(screen.queryByText(/internal crash/)).toBeNull();
expect(onSuccess).not.toHaveBeenCalled();
});
});

9
website/vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['tests/**/*.test.tsx', 'tests/**/*.test.ts'],
},
});