API Feature Tests — User Portal
Feature tests for all 41 User Portal API endpoints (/api/v1/*). Each test file maps
to one API module. Tests use Sanctum token auth via actingAs($user, 'sanctum').
Profile — tests/Feature/Api/ProfileTest.php
GET /me
✓ returns authenticated user's own profile
✓ returns 401 for unauthenticated request
✓ response includes firm name from firm_user_profiles
✓ does not return fields from another firm's user
PATCH /me
✓ updates display_name successfully
✓ updates notification preferences (json field)
✓ returns 422 for invalid email format
✓ returns 422 for empty display_name
✓ cannot update law_firm_id (ignored silently)
✓ returns 401 for unauthenticated request
Firm Members — tests/Feature/Api/MembersTest.php
GET /firm/members
✓ returns only active members of the authenticated user's firm
✓ does not return members from a different firm
✓ filters by role query param
✓ filters by name search query param
✓ excludes inactive profiles (is_active = false)
✓ paginates results
✓ returns 401 for unauthenticated request
GET /firm/members/{userId}
✓ returns full profile for a member in the same firm
✓ returns 404 for a userId that belongs to a different firm
✓ returns 404 for a non-existent userId
✓ returns 401 for unauthenticated request
Cases / Matters — tests/Feature/Api/Cases/
CaseListTest.php
GET /cases
✓ returns only matters assigned to the authenticated user (non-admin)
✓ FIRM_ADMIN role sees all firm matters
✓ filters by status param
✓ filters by priority param
✓ filters by lead_attorney_id param
✓ full-text search by title returns matching matters
✓ does not return matters from a different firm
✓ paginates results correctly
✓ returns 401 for unauthenticated request
CaseCreateTest.php
POST /cases
✓ LAWYER role creates a matter with all required fields
✓ assigns lead_attorney_id to current user when not provided
✓ returns 403 for PARALEGAL role (cannot create)
✓ returns 403 for CLIENT_PORTAL role
✓ returns 422 for missing title
✓ returns 422 for invalid status value (not in enum)
✓ returns 422 for invalid case_type
✓ created matter is scoped to authenticated user's firm
✓ returns 401 for unauthenticated request
CaseDetailTest.php
GET /cases/{caseId}
✓ returns full matter details including team members
✓ PARALEGAL on the case team can view the matter
✓ returns 403 for user not on the case team (non-admin)
✓ FIRM_ADMIN can view any matter in their firm
✓ returns 403 for matter belonging to a different firm
✓ returns 404 for non-existent matter id
✓ returns 401 for unauthenticated request
CaseUpdateTest.php
PATCH /cases/{caseId}
✓ lead attorney can update status
✓ valid status transition: INTAKE → ACTIVE succeeds
✓ invalid status transition: CLOSED → ACTIVE returns 422
✓ returns 403 for PARALEGAL trying to close a matter
✓ returns 403 for user not on the case team
✓ returns 403 for matter in a different firm
✓ returns 422 for invalid priority value
✓ returns 401 for unauthenticated request
CaseMembersTest.php
GET /cases/{caseId}/members
✓ returns all team members with their roles
✓ returns 404 for unknown matter
POST /cases/{caseId}/members
✓ lead attorney adds a team member with a role
✓ returns 409 if user is already a team member
✓ returns 403 if requester is not the lead attorney
✓ returns 422 for invalid role_type
DELETE /cases/{caseId}/members/{userId}
✓ lead attorney removes a team member
✓ returns 403 when trying to remove the lead attorney
✓ returns 403 if requester is not the lead attorney
✓ returns 404 if userId is not on the case team
Clients — tests/Feature/Api/ClientsTest.php
GET /clients
✓ returns all firm clients
✓ does not return clients from a different firm
✓ filters by type (INDIVIDUAL / BUSINESS)
✓ search by name returns matching clients
✓ paginates results
POST /clients
✓ creates an INDIVIDUAL client with required fields
✓ creates a BUSINESS client with required fields
✓ returns 422 for BUSINESS type missing company_name
✓ SSN value is stored encrypted (raw value not returned in response)
✓ returns 403 for INTERN role
✓ returns 422 for invalid email format
✓ returns 401 for unauthenticated request
GET /clients/{clientId}
✓ returns full client details
✓ SSN is masked for non-BILLING_ADMIN roles
✓ SSN is visible for BILLING_ADMIN role
✓ returns 404 for client in a different firm
✓ returns 401 for unauthenticated request
PATCH /clients/{clientId}
✓ updates allowed client fields
✓ returns 403 for INTERN role
✓ returns 422 for invalid email format
✓ returns 404 for client in a different firm
Documents — tests/Feature/Api/DocumentsTest.php
GET /documents
✓ returns documents for matters the user is assigned to
✓ does not return documents from matters in a different firm
✓ filters by category
✓ filters by status
✓ filters by matter_id
POST /documents
✓ returns a presigned S3 URL for upload
✓ creates document record in DRAFT status
✓ returns 422 for missing filename
✓ returns 422 for missing matter_id
✓ returns 403 if user has no access to the specified matter
✓ returns 401 for unauthenticated request
GET /documents/{documentId}
✓ returns document metadata and a time-limited download URL
✓ download URL is a presigned S3 URL (not a direct file path)
✓ returns 403 for user without access to the parent matter
✓ returns 404 for document in a different firm
✓ returns 401 for unauthenticated request
PATCH /documents/{documentId}
✓ updates metadata (title, category)
✓ returns 403 if document status is FINAL (cannot edit)
✓ returns 422 for invalid status transition (FINAL → DRAFT)
✓ returns 404 for document in a different firm
DELETE /documents/{documentId}
✓ soft-deletes the document (deleted_at set, record still in DB)
✓ deleted document returns 404 on subsequent GET
✓ returns 403 if requester is not the document owner or FIRM_ADMIN
✓ returns 404 for document in a different firm
Appointments — tests/Feature/Api/AppointmentsTest.php
GET /appointments
✓ returns appointments where authenticated user is an attendee
✓ does not return appointments from a different firm
✓ filters by date range
✓ filters by type (CONSULTATION, COURT_HEARING, etc.)
✓ includes past appointments (not filtered out by default)
✓ paginates results
POST /appointments
✓ creates appointment with multiple attendees
✓ sends notification to each attendee on creation
✓ returns 422 for end_time before start_time
✓ returns 422 for missing type
✓ returns 422 for invalid type value
✓ returns 401 for unauthenticated request
GET /appointments/{appointmentId}
✓ returns full appointment details with attendee list
✓ returns 403 if authenticated user is not an attendee
✓ returns 404 for appointment in a different firm
PATCH /appointments/{appointmentId}
✓ organizer can update appointment details
✓ returns 403 if requester is not the organizer
✓ returns 422 if appointment status is already CANCELLED
✓ returns 401 for unauthenticated request
DELETE /appointments/{appointmentId}
✓ organizer cancels the appointment
✓ creates an CANCELLED event in appointment_events (append-only log)
✓ returns 403 if requester is not the organizer
✓ returns 404 for appointment in a different firm
POST /appointments/{appointmentId}/respond
✓ attendee responds ACCEPTED
✓ attendee responds DECLINED
✓ attendee responds TENTATIVE
✓ returns 422 for invalid response value
✓ returns 404 if authenticated user is not an attendee
Time Entries — tests/Feature/Api/TimeEntries/
TimeEntryCrudTest.php
GET /time-entries
✓ returns own time entries (user sees only their own)
✓ BILLING_ADMIN sees all firm time entries
✓ filters by date range
✓ filters by matter_id
✓ filters by status (DRAFT / SUBMITTED / APPROVED)
POST /time-entries
✓ creates time entry in DRAFT status
✓ rate is resolved correctly (matter-level rate applied)
✓ rate falls back to client rate when no matter rate
✓ rate falls back to lawyer default when no client rate
✓ returns 422 for negative duration_minutes
✓ returns 422 for duration_minutes = 0
✓ returns 422 for future date
✓ returns 403 for non-billable role (e.g. RECEPTIONIST)
✓ returns 401 for unauthenticated request
GET /time-entries/{timeEntryId}
✓ returns own entry with full details
✓ returns 403 for another user's entry (when not BILLING_ADMIN)
✓ BILLING_ADMIN can view any firm entry
✓ returns 404 for entry in a different firm
PATCH /time-entries/{timeEntryId}
✓ updates a DRAFT entry
✓ returns 403 on a SUBMITTED entry (immutable after submit)
✓ returns 403 on an APPROVED entry
✓ returns 422 for invalid duration_minutes
DELETE /time-entries/{timeEntryId}
✓ soft-deletes a DRAFT entry
✓ returns 403 on a SUBMITTED entry
✓ hard delete is never performed (record remains in DB)
TimeEntrySubmitTest.php
POST /time-entries/{timeEntryId}/submit
✓ transitions DRAFT entry to SUBMITTED
✓ returns 409 if entry is already SUBMITTED
✓ returns 409 if entry is already APPROVED
✓ returns 422 if duration_minutes is 0
✓ returns 403 for another user's entry
Invoices — tests/Feature/Api/InvoicesTest.php
GET /invoices
✓ returns invoices for matters the user is assigned to
✓ BILLING_ADMIN sees all firm invoices
✓ CLIENT_PORTAL guard sees only their own client's invoices
✓ does not return invoices from a different firm
✓ filters by status
✓ filters by date range
✓ paginates results
✓ returns 401 for unauthenticated request
GET /invoices/{invoiceId}
✓ returns full invoice with line items
✓ includes a presigned PDF download URL
✓ returns 403 for user without billing:read permission
✓ CLIENT_PORTAL user can view their own invoice
✓ CLIENT_PORTAL user returns 403 for another client's invoice
✓ returns 403 for invoice in a different firm
✓ returns 404 for non-existent invoice
Notifications — tests/Feature/Api/NotificationsTest.php
GET /notifications
✓ returns current user's own notifications only
✓ does not return another user's notifications
✓ unread notifications appear first
✓ paginates results
✓ returns 401 for unauthenticated request
POST /notifications/{notificationId}/mark-read
✓ marks the notification as read (read_at set)
✓ returns 404 for another user's notification
✓ returns 404 for non-existent notification
✓ returns 401 for unauthenticated request
POST /notifications/mark-all-read
✓ marks all current user's unread notifications as read
✓ does not affect another user's notifications
✓ does not affect notifications from a different firm
Comments — tests/Feature/Api/CommentsTest.php
GET /comments?resourceType=CASE&resourceId={id}
✓ returns comments for the specified matter
✓ returns 403 if user has no access to the parent matter
✓ returns 422 for missing resourceType or resourceId
✓ paginates results
POST /comments
✓ creates comment on a matter
✓ creates comment on a document
✓ returns 403 if user has no access to the parent resource
✓ returns 422 for empty content
✓ returns 422 for missing resourceType
✓ returns 401 for unauthenticated request
PATCH /comments/{commentId}
✓ user edits their own comment
✓ returns 403 when editing another user's comment
✓ edit history is recorded on the comment record
✓ returns 422 for empty content
✓ returns 401 for unauthenticated request
DELETE /comments/{commentId}
✓ user deletes their own comment
✓ FIRM_ADMIN can delete any comment in their firm
✓ returns 403 when deleting another user's comment (non-admin)
✓ returns 404 for comment in a different firm