diff --git a/e2e/tests/workflows/file-upload-test.spec.ts b/e2e/tests/workflows/file-upload-test.spec.ts new file mode 100644 index 0000000..77d0ebd --- /dev/null +++ b/e2e/tests/workflows/file-upload-test.spec.ts @@ -0,0 +1,296 @@ +/* eslint-disable */ +/** + * FILE UPLOAD TESTS — verify attachment uploads work across modules. + * + * Tests: observation public form upload, RCA attachment, complaint attachment + * (if supported), PX Action evidence. + * + * Run headed: + * E2E_MAXIMIZED=1 npx playwright test --headed --project chromium file-upload-test --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { attachObservers, observe, loginAndScope, OBS, BASE_URL, getE2EHospitalId } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const M = 'FileUpload'; +const ADMIN: RoleName = 'hospital_admin'; + +type Page = import('@playwright/test').Page; + +function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } +function shellPy(code: string): string { + const tmpFile = `/tmp/e2e_up_${Date.now()}_${Math.random().toString(36).slice(2,6)}.py`; + fs.writeFileSync(tmpFile, code); + const out = uv(`uv run manage.py shell < ${tmpFile}`); + try { fs.unlinkSync(tmpFile); } catch {} + return out; +} + +// Create a test file for upload +const TEST_FILE = '/tmp/e2e_test_upload.txt'; +const TEST_FILE_PDF = '/tmp/e2e_test_upload.pdf'; +if (!fs.existsSync(TEST_FILE)) fs.writeFileSync(TEST_FILE, `E2E test file upload ${Date.now()}`); +// Minimal valid PDF +if (!fs.existsSync(TEST_FILE_PDF)) { + fs.writeFileSync(TEST_FILE_PDF, '%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f \ntrailer<>\nstartxref\n0\n%%EOF'); +} + +async function login(page: Page) { await page.context().clearCookies(); await loginAndScope(page, ADMIN, M); } +async function csrfOf(page: Page): Promise { + return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || ''); +} + +test.describe('File Upload Tests', () => { + test.describe.configure({ mode: 'serial' }); + + // ── 1. Observation public form: file attachment upload ────────────────── + test('Observation: upload attachment via public form', async ({ page }) => { + attachObservers(page, M, 'anonymous'); + const hospId = await getE2EHospitalId(page); + try { + // Get a fresh CSRF from the public form + await page.goto(`${BASE_URL}/observations/new/`); + await page.waitForSelector('#observationForm, form[enctype]', { timeout: 10000 }); + const csrf = await csrfOf(page); + + // Upload via the form's file input + const fileInput = page.locator('input[type="file"][name="attachments"]').first(); + if (!(await fileInput.count())) { + observe(M, 'obs-upload-input', 'WARN', 'no file input found on observation form', {}); + return; + } + + await fileInput.setInputFiles(TEST_FILE); + + // Fill required fields + submit + const ts = Date.now(); + await page.locator('textarea[name="description"]').first().fill(`E2E upload test ${ts}`); + await page.locator('input[name="reporter_name"]').first().fill(`E2E Upload ${ts}`).catch(() => {}); + await page.locator('input[name="reporter_phone"]').first().fill('0550000000').catch(() => {}); + + // POST directly (the form uses AJAX but we can POST the multipart) + const r = await page.context().request.post(`${BASE_URL}/observations/new/`, { + multipart: { + csrfmiddlewaretoken: csrf, + hospital: hospId, + description: `E2E upload test ${ts}`, + incident_datetime: '2026-06-18 12:00:00', + reporter_name: `E2E Upload ${ts}`, + reporter_phone: '0550000000', + attachments: { + name: 'e2e_test_upload.txt', + mimeType: 'text/plain', + buffer: fs.readFileSync(TEST_FILE), + }, + }, + }); + + let uploaded = false; + try { + const data = await r.json(); + uploaded = data.success === true; + observe(M, 'obs-upload', uploaded ? 'PASS' : 'WARN', + `observation with attachment: HTTP ${r.status()} success=${data.success}`, { http: r.status() }); + } catch { + // Non-JSON response — check if it was a redirect (302 = success for form POST) + uploaded = r.status() === 302; + observe(M, 'obs-upload', uploaded ? 'PASS' : 'WARN', + `observation with attachment: HTTP ${r.status()}`, { http: r.status() }); + } + + // Verify the attachment was saved + if (uploaded) { + const attOut = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.observations.models import ObservationAttachment, Observation +from apps.organizations.models import Hospital +e = Hospital.objects.get(code='E2E-HOSP') +o = Observation.objects.filter(hospital=e).order_by('-created_at').first() +if o: + atts = o.attachments.all() + print(f'ATTACHMENTS={atts.count()} FIRST={atts.first().filename if atts.exists() else "NONE"}') +else: + print('ATTACHMENTS=0 FIRST=NONE') +`); + const m = attOut.match(/ATTACHMENTS=(\d+)\s+FIRST=(\S+)/); + if (m) { + observe(M, 'obs-upload-verify', parseInt(m[1]) > 0 ? 'PASS' : 'WARN', + `attachments on newest observation: ${m[1]} (first=${m[2]})`, {}); + } + } + } catch (e) { + observe(M, 'obs-upload', 'FAIL', `exception: ${(e as Error).message}`, {}); + } + }); + + // ── 2. RCA attachment upload ──────────────────────────────────────────── + test('RCA: upload attachment', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + // Get/create an RCA + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.rca.models import RootCauseAnalysis +from apps.organizations.models import Hospital +h = Hospital.objects.get(id='${hospId}') +r = RootCauseAnalysis.objects.filter(hospital=h).first() +if r is None: + r = RootCauseAnalysis.objects.create(hospital=h, title='E2E Upload RCA', description='test', severity='low', status='draft') +print(f'RID={r.id}') +`); + const rid = out.match(/RID=(\S+)/)?.[1] || ''; + if (!rid) { observe(M, 'rca-upload', 'FAIL', 'no RCA', {}); return; } + + // Navigate to RCA detail + await page.goto(`${BASE_URL}/rca/${rid}/`); + await page.waitForLoadState('domcontentloaded'); + + // Upload via the attachment form (if it exists on the page) + const csrf = await csrfOf(page); + const r = await page.context().request.post(`${BASE_URL}/rca/${rid}/attachments/add/`, { + multipart: { + csrfmiddlewaretoken: csrf, + file: { + name: 'e2e_test_upload.txt', + mimeType: 'text/plain', + buffer: fs.readFileSync(TEST_FILE), + }, + description: 'E2E test attachment', + }, + }); + + observe(M, 'rca-upload', r.status() < 400 ? 'PASS' : 'WARN', + `RCA attachment upload: HTTP ${r.status()}`, { http: r.status() }); + + // Verify + const attOut = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.rca.models import RootCauseAnalysis, RCAAttachment +r = RootCauseAnalysis.objects.get(id='${rid}') +att_count = r.attachments.count() if hasattr(r, "attachments") else RCAAttachment.objects.filter(rca=r).count() +print('COUNT=' + str(att_count)) +`); + const m = attOut.match(/COUNT=(\d+)/); + observe(M, 'rca-upload-verify', m && parseInt(m[1]) > 0 ? 'PASS' : 'WARN', + `RCA attachments: ${m ? m[1] : '?'}`, {}); + } catch (e) { + observe(M, 'rca-upload', 'FAIL', `exception: ${(e as Error).message}`, {}); + } + }); + + // ── 3. Complaint attachment (via detail page AJAX or direct upload) ───── + test('Complaint: attachment upload on detail', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +c = Complaint.objects.filter(hospital=h).first() +print(f'CID={c.id}') +`); + const cid = out.match(/CID=(\S+)/)?.[1] || ''; + if (!cid) { observe(M, 'complaint-upload', 'FAIL', 'no complaint', {}); return; } + + // Check if there's a complaint attachment upload endpoint + // The complaint attachment is typically uploaded via the complaint detail page's + // attachment section. Let's check if there's a form. + await page.goto(`${BASE_URL}/complaints/${cid}/`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(1000); + + const hasFileInput = await page.locator('input[type="file"]').count(); + observe(M, 'complaint-upload-input', hasFileInput > 0 ? 'PASS' : 'WARN', + `complaint detail has file input: ${hasFileInput > 0} (${hasFileInput} inputs)`, {}); + + if (hasFileInput > 0) { + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(TEST_FILE); + // Try to submit the upload form + const submitBtn = page.locator('form:has(input[type="file"]) button[type="submit"]').first(); + if (await submitBtn.count()) { + await submitBtn.click().catch(() => {}); + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); + await page.waitForTimeout(1000); + observe(M, 'complaint-upload', 'PASS', 'attachment uploaded via UI', {}); + } else { + observe(M, 'complaint-upload', 'WARN', 'file input found but no submit button', {}); + } + } else { + observe(M, 'complaint-upload', 'WARN', 'no file upload on complaint detail (may be via API only)', {}); + } + } catch (e) { + observe(M, 'complaint-upload', 'FAIL', `exception: ${(e as Error).message}`, {}); + } + }); + + // ── 4. Verify uploaded files exist in media/ ─────────────────────────── + test('Verify uploaded files exist in media/', async ({ page }) => { + attachObservers(page, M, ADMIN); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.observations.models import ObservationAttachment +from apps.rca.models import RCAAttachment +obs_atts = ObservationAttachment.objects.filter(filename__icontains='e2e').count() +rca_atts = RCAAttachment.objects.filter(filename__icontains='e2e').count() +print(f'OBS_ATTS={obs_atts} RCA_ATTS={rca_atts}') + +# Check media dir +import os +media_root = os.path.join(os.getcwd(), 'media') +if os.path.isdir(media_root): + files = [] + for root, dirs, fnames in os.walk(media_root): + for f in fnames: + if 'e2e' in f.lower() or 'upload' in f.lower(): + files.append(os.path.join(root, f)) + print(f'MEDIA_FILES={len(files)}') + for fp in files[:3]: + print(f'FILE={fp} SIZE={os.path.getsize(fp)}') +else: + print('MEDIA_FILES=0') +`); + const obsM = out.match(/OBS_ATTS=(\d+)/); + const rcaM = out.match(/RCA_ATTS=(\d+)/); + const mediaM = out.match(/MEDIA_FILES=(\d+)/); + const obsCount = obsM ? parseInt(obsM[1]) : 0; + const rcaCount = rcaM ? parseInt(rcaM[1]) : 0; + const mediaCount = mediaM ? parseInt(mediaM[1]) : 0; + + observe(M, 'obs-attachments-in-db', obsCount > 0 ? 'PASS' : 'WARN', + `observation attachments with 'e2e' in name: ${obsCount}`, {}); + observe(M, 'rca-attachments-in-db', rcaCount > 0 ? 'PASS' : 'WARN', + `RCA attachments with 'e2e' in name: ${rcaCount}`, {}); + observe(M, 'media-files-exist', mediaCount > 0 ? 'PASS' : 'WARN', + `matching files in media/: ${mediaCount}`, {}); + } catch (e) { + observe(M, 'verify', 'FAIL', `exception: ${(e as Error).message}`, {}); + } + }); +}); + +test.afterAll(async () => { + const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); + console.log('\n=========== FILE UPLOAD SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('===========================================\n'); +});