diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3c7abf8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,78 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + backend-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 123123 + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres + JWT_SECRET: test-jwt-secret-for-ci + DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci + + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Check + run: cargo check --workspace + + - name: Run unit tests + run: cargo test --workspace --lib --bins -- --test-threads=2 + + - name: Run integration tests + run: cargo test -p erp-server --test integration -- --test-threads=1 + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + frontend-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: apps/web/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - name: TypeScript check + run: npx tsc --noEmit + + - name: Run unit tests + run: pnpm test -- --run + + - name: Build + run: pnpm build diff --git a/apps/web/package.json b/apps/web/package.json index d07a8b6..4e627ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" }, @@ -38,15 +39,22 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^29.0.2", + "msw": "^2.13.6", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", "vite": "^8.0.4", "vitest": "^4.1.5" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js new file mode 100644 index 0000000..80f1930 --- /dev/null +++ b/apps/web/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.13.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/web/src/test/mocks/handlers.ts b/apps/web/src/test/mocks/handlers.ts new file mode 100644 index 0000000..11a8580 --- /dev/null +++ b/apps/web/src/test/mocks/handlers.ts @@ -0,0 +1,30 @@ +import { http, HttpResponse } from 'msw'; + +const TOKEN_EXPIRES = 3600; + +export const authHandlers = [ + http.post('/api/v1/auth/login', () => + HttpResponse.json({ + success: true, + data: { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_in: TOKEN_EXPIRES, + user: { id: 'test-user-id', username: 'admin', tenant_id: 'test-tenant-id' }, + }, + }), + ), + + http.post('/api/v1/auth/refresh', () => + HttpResponse.json({ + success: true, + data: { + access_token: 'test-access-token-refreshed', + refresh_token: 'test-refresh-token-refreshed', + expires_in: TOKEN_EXPIRES, + }, + }), + ), +]; + +export const handlers = [...authHandlers]; diff --git a/apps/web/src/test/mocks/server.ts b/apps/web/src/test/mocks/server.ts new file mode 100644 index 0000000..e52fee0 --- /dev/null +++ b/apps/web/src/test/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index c44951a..a695e55 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1 +1,6 @@ -import '@testing-library/jest-dom' +import '@testing-library/jest-dom'; +import { server } from './mocks/server'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 4935b08..d0a3d4b 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -9,6 +9,12 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test/setup.ts'], exclude: ['e2e/**', 'node_modules/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/test/**', 'src/**/*.test.*', 'src/**/*.d.ts'], + }, }, resolve: { alias: { diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index 6889533..55d3f26 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -1,5 +1,7 @@ #[path = "integration/test_db.rs"] mod test_db; +#[path = "integration/test_fixture.rs"] +mod test_fixture; #[path = "integration/auth_tests.rs"] mod auth_tests; #[path = "integration/plugin_tests.rs"] diff --git a/crates/erp-server/tests/integration/test_fixture.rs b/crates/erp-server/tests/integration/test_fixture.rs new file mode 100644 index 0000000..f624d7c --- /dev/null +++ b/crates/erp-server/tests/integration/test_fixture.rs @@ -0,0 +1,137 @@ +use chrono::{NaiveDate, NaiveTime}; +use erp_core::crypto::PiiCrypto; +use erp_core::events::EventBus; +use erp_health::dto::appointment_dto::{CreateAppointmentReq, CreateScheduleReq}; +use erp_health::dto::doctor_dto::CreateDoctorReq; +use erp_health::dto::patient_dto::CreatePatientReq; +use erp_health::service::{appointment_service, doctor_service, patient_service}; +use erp_health::state::HealthState; + +use super::test_db::TestDb; + +/// 集成测试环境 — 封装 TestDb + HealthState + 租户/操作者上下文 +pub struct TestApp { + test_db: TestDb, + health_state: HealthState, + tenant_id: uuid::Uuid, + operator_id: uuid::Uuid, +} + +impl TestApp { + pub async fn new() -> Self { + let test_db = TestDb::new().await; + let health_state = HealthState { + db: test_db.db().clone(), + event_bus: EventBus::new(100), + crypto: PiiCrypto::dev_default(), + }; + Self { + test_db, + health_state, + tenant_id: uuid::Uuid::new_v4(), + operator_id: uuid::Uuid::new_v4(), + } + } + + pub fn db(&self) -> &sea_orm::DatabaseConnection { + self.test_db.db() + } + + pub fn health_state(&self) -> &HealthState { + &self.health_state + } + + pub fn tenant_id(&self) -> uuid::Uuid { + self.tenant_id + } + + pub fn operator_id(&self) -> uuid::Uuid { + self.operator_id + } + + // ---- Fixture 工厂方法 ---- + + pub async fn create_patient(&self, name: &str) -> uuid::Uuid { + let req = CreatePatientReq { + name: name.to_string(), + gender: Some("male".to_string()), + birth_date: None, + blood_type: None, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }; + let patient = patient_service::create_patient( + self.health_state(), self.tenant_id, Some(self.operator_id), req, + ) + .await + .expect("创建患者应成功"); + patient.id + } + + pub async fn create_doctor(&self, name: &str) -> uuid::Uuid { + let req = CreateDoctorReq { + user_id: None, + name: name.to_string(), + department: Some("内科".to_string()), + title: Some("主治医师".to_string()), + specialty: Some("心血管内科".to_string()), + license_number: None, + bio: None, + }; + let doctor = doctor_service::create_doctor( + self.health_state(), self.tenant_id, Some(self.operator_id), req, + ) + .await + .expect("创建医护档案应成功"); + doctor.id + } + + pub async fn create_schedule( + &self, + doctor_id: uuid::Uuid, + date: NaiveDate, + ) -> uuid::Uuid { + let req = CreateScheduleReq { + doctor_id, + schedule_date: date, + period_type: Some("am".to_string()), + start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + end_time: NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + max_appointments: 10, + }; + let schedule = appointment_service::create_schedule( + self.health_state(), self.tenant_id, Some(self.operator_id), req, + ) + .await + .expect("创建排班应成功"); + schedule.id + } + + pub async fn create_appointment( + &self, + patient_id: uuid::Uuid, + doctor_id: uuid::Uuid, + date: NaiveDate, + ) -> uuid::Uuid { + let req = CreateAppointmentReq { + patient_id, + doctor_id: Some(doctor_id), + appointment_type: Some("outpatient".to_string()), + appointment_date: date, + start_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + end_time: NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + notes: Some("测试预约".to_string()), + }; + let appt = appointment_service::create_appointment( + self.health_state(), self.tenant_id, Some(self.operator_id), req, + ) + .await + .expect("创建预约应成功"); + appt.id + } +}