Saltar a contenido

Estrategia de Testing de MedTime

Identificador: TECH-TST-001 Version: 1.1.0 Fecha: 2025-12-09 Ultima Revision: DV2-P3 - Remediacion de hallazgos de prioridad baja Autor: TestingDrone / SpecQueen Technical Division Refs Arquitectura: TECH-ARC-001, TECH-CS-001

1. Changelog

1.1. Version 1.1.0 (2025-12-09) - DV2-P3

  • TEST-BAJO-001: Agregada seccion completa de Database Migration Tests (4.1.1)
  • Tests de migracion iOS (Realm)
  • Tests de migracion Android (Room)
  • Tests de migracion PostgreSQL
  • CI integration para migration tests
  • TEST-BAJO-002: Agregada configuracion completa de Security Scanning Tools (6.4.1)
  • SAST: Snyk, SonarQube, SwiftLint, Detekt, Semgrep, GitGuardian
  • DAST: OWASP ZAP, MobSF
  • Configuraciones detalladas y CI workflows
  • TEST-BAJO-003: Agregados Load Testing Scenarios realistas (7.4.1)
  • 6 scenarios completos: Smoke, Load, Stress, Spike, Soak, Concurrent
  • Load test execution matrix
  • CI integration con k6
  • SLOs y metricas de performance

1.2. Version 1.0.0 (2025-12-08) - IT-06

  • Creacion inicial del documento


2. Introduccion

2.1. Proposito

Este documento define la estrategia de testing para MedTime, asegurando:

  • Calidad del software en todas las capas
  • Compliance con regulaciones (HIPAA, LGPD, FDA)
  • Seguridad de datos de salud (PHI)
  • Funcionamiento offline-first confiable

2.2. Alcance

Componente Incluido Notas
iOS App Si Swift, Realm, XCTest
Android App Si Kotlin, Room, JUnit
Cloud Functions Si Node.js, Jest
PostgreSQL Si pgTAP, RLS tests
APIs Si Contract testing

2.3. Principios de Testing

Principio Descripcion
Shift-Left Testing temprano en el ciclo de desarrollo
Test Pyramid Mas unit tests, menos E2E
TDD Test-Driven Development donde aplique
Zero PHI in Tests Nunca datos reales de pacientes
Reproducible Tests deterministas y repetibles

3. Piramide de Testing

3.1. Distribucion de Tests

graph TB
    subgraph Piramide["PIRAMIDE DE TESTING"]
        E2E["E2E Tests<br/>5% - Flujos criticos"]
        INT["Integration Tests<br/>15% - APIs, DB, Services"]
        UNIT["Unit Tests<br/>80% - Logica de negocio"]
    end

    E2E --> INT
    INT --> UNIT

    style E2E fill:#ff9999
    style INT fill:#99ccff
    style UNIT fill:#99ff99

3.2. Cobertura por Capa

Capa Cobertura Target Cobertura Minima Tipo Principal
Domain (Entities, UseCases) 95% 90% Unit
Data (Repositories, DataSources) 85% 80% Unit + Integration
Presentation (ViewModels) 80% 70% Unit
Infrastructure (APIs, DB) 90% endpoints 85% Integration
UI (Screens, Flows) Critical paths - E2E

4. Unit Tests

4.1. Cliente iOS

Framework: XCTest + Quick/Nimble

// Ejemplo: Test de UseCase de medicamentos
import XCTest
@testable import MedTime

class AddMedicationUseCaseTests: XCTestCase {

    var sut: AddMedicationUseCase!
    var mockRepository: MockMedicationRepository!
    var mockEncryption: MockEncryptionService!

    override func setUp() {
        super.setUp()
        mockRepository = MockMedicationRepository()
        mockEncryption = MockEncryptionService()
        sut = AddMedicationUseCase(
            repository: mockRepository,
            encryption: mockEncryption
        )
    }

    func test_addMedication_success_encryptsAndSaves() async throws {
        // Given
        let medication = Medication.fixture()

        // When
        try await sut.execute(medication: medication)

        // Then
        XCTAssertTrue(mockEncryption.encryptCalled)
        XCTAssertTrue(mockRepository.saveCalled)
        XCTAssertEqual(mockRepository.savedMedication?.id, medication.id)
    }

    func test_addMedication_duplicateName_throwsError() async {
        // Given
        mockRepository.existsByNameResult = true
        let medication = Medication.fixture()

        // When/Then
        await XCTAssertThrowsError(
            try await sut.execute(medication: medication)
        ) { error in
            XCTAssertEqual(error as? MedicationError, .duplicateName)
        }
    }
}

Cobertura por modulo:

Modulo Target Critico
Domain/Entities 95% 90%
Domain/UseCases 95% 90%
Data/Repositories 85% 80%
Presentation/ViewModels 80% 70%

4.2. Cliente Android

Framework: JUnit 5 + MockK + Turbine (Flow testing)

// Ejemplo: Test de ViewModel
class MedicationListViewModelTest {

    @MockK
    lateinit var getMedicationsUseCase: GetMedicationsUseCase

    @MockK
    lateinit var deleteMedicationUseCase: DeleteMedicationUseCase

    private lateinit var viewModel: MedicationListViewModel

    @Before
    fun setup() {
        MockKAnnotations.init(this)
        viewModel = MedicationListViewModel(
            getMedicationsUseCase,
            deleteMedicationUseCase
        )
    }

    @Test
    fun `loadMedications success updates state with medications`() = runTest {
        // Given
        val medications = listOf(Medication.fixture(), Medication.fixture())
        coEvery { getMedicationsUseCase() } returns flowOf(medications)

        // When
        viewModel.loadMedications()

        // Then
        viewModel.uiState.test {
            val state = awaitItem()
            assertEquals(medications, state.medications)
            assertEquals(false, state.isLoading)
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `deleteMedication success removes from list`() = runTest {
        // Given
        val medicationId = UUID.randomUUID()
        coEvery { deleteMedicationUseCase(medicationId) } returns Unit

        // When
        viewModel.deleteMedication(medicationId)

        // Then
        coVerify { deleteMedicationUseCase(medicationId) }
    }
}

4.3. Servidor

Framework: Jest + Supertest

// Ejemplo: Test de Cloud Function
describe('validateMedicationBlob', () => {
    let mockFirestore;

    beforeEach(() => {
        mockFirestore = createMockFirestore();
        jest.clearAllMocks();
    });

    test('should accept valid encrypted blob', async () => {
        // Given
        const validBlob = {
            blob_id: 'uuid-here',
            entity_type: 'medication',
            encrypted_data: 'base64-encrypted-content',
            checksum: 'sha256-hash',
            version: 1
        };

        // When
        const result = await validateMedicationBlob(validBlob);

        // Then
        expect(result.valid).toBe(true);
        expect(result.errors).toHaveLength(0);
    });

    test('should reject blob with invalid checksum', async () => {
        // Given
        const invalidBlob = {
            blob_id: 'uuid-here',
            encrypted_data: 'some-data',
            checksum: 'wrong-checksum'
        };

        // When
        const result = await validateMedicationBlob(invalidBlob);

        // Then
        expect(result.valid).toBe(false);
        expect(result.errors).toContain('CHECKSUM_MISMATCH');
    });
});

4.4. Estrategias de Mocking

Capa Que Mockear Como
UseCase Repository, Services Protocol/Interface mocks
Repository DataSource In-memory implementations
ViewModel UseCases Mock returns
API Client Network Mock server (WireMock)
Database - In-memory SQLite/Realm

Regla de oro: Mock las dependencias, no la implementacion.


5. Integration Tests

5.1. Database Tests

5.1.1. Database Migration Tests (DV2-P3)

Agregado: TEST-BAJO-001 - Tests completos para migraciones de base de datos.

Objetivo: Garantizar que las migraciones de esquema preservan datos y mantienen integridad referencial.

5.1.1.1. Migration Test Strategy
flowchart TD
    subgraph Proceso["MIGRATION TEST PROCESS"]
        S[Schema Version N] --> D[Populate Test Data]
        D --> M[Apply Migration N+1]
        M --> V[Validate Data Integrity]
        V --> R[Validate Rollback]
        R --> P[Performance Check]
    end

    style M fill:#ff9999
    style V fill:#99ff99
5.1.1.2. iOS/Android Local DB Migrations

iOS (Realm) Migration Tests:

// tests/migrations/RealmMigrationTests.swift
class RealmMigrationTests: XCTestCase {

    // MARK: - Migration v1 -> v2 (DV2-P3)
    func test_migration_v1_to_v2_preservesAllData() throws {
        // Given: Database v1 with test data
        let v1Config = Realm.Configuration(
            schemaVersion: 1,
            deleteRealmIfMigrationNeeded: false
        )

        let v1Realm = try Realm(configuration: v1Config)

        // Insert v1 data
        try v1Realm.write {
            v1Realm.add(MedicationV1(
                id: UUID().uuidString,
                name: "Metformin",
                dosage: "500mg",
                frequency: "twice_daily"
                // No createdAt field in v1
            ))
            v1Realm.add(MedicationV1(
                id: UUID().uuidString,
                name: "Lisinopril",
                dosage: "10mg",
                frequency: "once_daily"
            ))
        }

        let v1Count = v1Realm.objects(MedicationV1.self).count
        XCTAssertEqual(v1Count, 2)

        // When: Migrate to v2 (adds createdAt, updatedAt fields)
        let v2Config = Realm.Configuration(
            schemaVersion: 2,
            migrationBlock: { migration, oldSchemaVersion in
                if oldSchemaVersion < 2 {
                    migration.enumerateObjects(ofType: MedicationV2.className()) { old, new in
                        // Populate new timestamp fields
                        new!["createdAt"] = Date()
                        new!["updatedAt"] = Date()
                    }
                }
            }
        )

        let v2Realm = try Realm(configuration: v2Config)

        // Then: All data preserved
        let v2Medications = v2Realm.objects(MedicationV2.self)
        XCTAssertEqual(v2Medications.count, 2)

        // Verify migrated data
        let metformin = v2Medications.first { $0.name == "Metformin" }
        XCTAssertNotNil(metformin)
        XCTAssertEqual(metformin?.dosage, "500mg")
        XCTAssertNotNil(metformin?.createdAt, "New field should be populated")
        XCTAssertNotNil(metformin?.updatedAt, "New field should be populated")
    }

    func test_migration_v2_to_v3_addsEncryptionMetadata() throws {
        // Given: v2 database
        let v2Config = Realm.Configuration(schemaVersion: 2)
        let v2Realm = try Realm(configuration: v2Config)

        try v2Realm.write {
            v2Realm.add(MedicationV2(
                id: UUID().uuidString,
                name: "Aspirin",
                dosage: "81mg"
                // No encryption_version field
            ))
        }

        // When: Migrate to v3 (adds encryption_version, last_encrypted_at)
        let v3Config = Realm.Configuration(
            schemaVersion: 3,
            migrationBlock: { migration, oldSchemaVersion in
                if oldSchemaVersion < 3 {
                    migration.enumerateObjects(ofType: MedicationV3.className()) { old, new in
                        new!["encryption_version"] = 1
                        new!["last_encrypted_at"] = Date()
                    }
                }
            }
        )

        let v3Realm = try Realm(configuration: v3Config)

        // Then: Encryption fields added
        let medications = v3Realm.objects(MedicationV3.self)
        XCTAssertEqual(medications.count, 1)
        XCTAssertEqual(medications.first?.encryption_version, 1)
        XCTAssertNotNil(medications.first?.last_encrypted_at)
    }

    func test_migration_rollback_restoresV1() throws {
        // Given: v2 database
        let v2Config = Realm.Configuration(schemaVersion: 2)
        let v2Realm = try Realm(configuration: v2Config)

        let medicationId = UUID().uuidString
        try v2Realm.write {
            v2Realm.add(MedicationV2(id: medicationId, name: "Test Med"))
        }

        // Create backup before migration
        let backupPath = v2Config.fileURL!.appendingPathComponent(".backup")
        try FileManager.default.copyItem(at: v2Config.fileURL!, to: backupPath)

        // When: Migration fails (simulated)
        // Restore from backup
        try FileManager.default.removeItem(at: v2Config.fileURL!)
        try FileManager.default.copyItem(at: backupPath, to: v2Config.fileURL!)

        // Then: v2 data intact
        let restoredRealm = try Realm(configuration: v2Config)
        let medications = restoredRealm.objects(MedicationV2.self)
        XCTAssertEqual(medications.count, 1)
        XCTAssertEqual(medications.first?.id, medicationId)
    }

    func test_migration_performance_under500ms() throws {
        // Given: v1 database with 1000 medications
        let v1Config = Realm.Configuration(schemaVersion: 1)
        let v1Realm = try Realm(configuration: v1Config)

        try v1Realm.write {
            for i in 0..<1000 {
                v1Realm.add(MedicationV1(
                    id: UUID().uuidString,
                    name: "Med \(i)",
                    dosage: "100mg"
                ))
            }
        }

        // When: Measure migration time
        let startTime = Date()

        let v2Config = Realm.Configuration(
            schemaVersion: 2,
            migrationBlock: MedTimeMigrations.v1ToV2
        )
        _ = try Realm(configuration: v2Config)

        let duration = Date().timeIntervalSince(startTime)

        // Then: Migration completes in < 500ms
        XCTAssertLessThan(duration, 0.5, "Migration of 1000 records should complete in < 500ms")
    }
}

Android (Room) Migration Tests:

// tests/migrations/RoomMigrationTest.kt
@RunWith(AndroidJUnit4::class)
class RoomMigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    // MARK: - Migration 1 -> 2 (DV2-P3)
    @Test
    fun migrate_1_to_2_preservesAllData() {
        // Given: v1 database with data
        val db = helper.createDatabase(TEST_DB, 1).apply {
            execSQL("""
                INSERT INTO medications (id, name, dosage, frequency)
                VALUES ('uuid-1', 'Metformin', '500mg', 'twice_daily')
            """)
            execSQL("""
                INSERT INTO medications (id, name, dosage, frequency)
                VALUES ('uuid-2', 'Lisinopril', '10mg', 'once_daily')
            """)
            close()
        }

        // When: Migrate to v2 (adds created_at, updated_at columns)
        val migratedDb = helper.runMigrationsAndValidate(
            TEST_DB,
            2,
            true,
            MIGRATION_1_2
        )

        // Then: Data preserved and new columns populated
        val cursor = migratedDb.query("SELECT * FROM medications")
        assertThat(cursor.count).isEqualTo(2)

        cursor.moveToFirst()
        assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Metformin")
        assertThat(cursor.getLong(cursor.getColumnIndex("created_at"))).isGreaterThan(0)
        assertThat(cursor.getLong(cursor.getColumnIndex("updated_at"))).isGreaterThan(0)

        cursor.moveToNext()
        assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Lisinopril")
        cursor.close()
    }

    @Test
    fun migrate_2_to_3_addsEncryptionFields() {
        // Given: v2 database
        val db = helper.createDatabase(TEST_DB, 2).apply {
            execSQL("""
                INSERT INTO medications (id, name, dosage, created_at, updated_at)
                VALUES ('uuid-1', 'Aspirin', '81mg', ${System.currentTimeMillis()}, ${System.currentTimeMillis()})
            """)
            close()
        }

        // When: Migrate to v3 (adds encryption_version, last_encrypted_at)
        val migratedDb = helper.runMigrationsAndValidate(
            TEST_DB,
            3,
            true,
            MIGRATION_2_3
        )

        // Then: Encryption fields added
        val cursor = migratedDb.query("SELECT * FROM medications")
        cursor.moveToFirst()
        assertThat(cursor.getInt(cursor.getColumnIndex("encryption_version"))).isEqualTo(1)
        assertThat(cursor.getLong(cursor.getColumnIndex("last_encrypted_at"))).isGreaterThan(0)
        cursor.close()
    }

    @Test
    fun migrate_1_to_3_skipsV2() {
        // Given: v1 database
        val db = helper.createDatabase(TEST_DB, 1).apply {
            execSQL("""
                INSERT INTO medications (id, name, dosage, frequency)
                VALUES ('uuid-1', 'Test Med', '100mg', 'daily')
            """)
            close()
        }

        // When: Direct migration 1 -> 3
        val migratedDb = helper.runMigrationsAndValidate(
            TEST_DB,
            3,
            true,
            MIGRATION_1_2,
            MIGRATION_2_3
        )

        // Then: All fields present
        val cursor = migratedDb.query("SELECT * FROM medications")
        cursor.moveToFirst()
        assertThat(cursor.getString(cursor.getColumnIndex("name"))).isEqualTo("Test Med")
        assertThat(cursor.getLong(cursor.getColumnIndex("created_at"))).isGreaterThan(0)
        assertThat(cursor.getInt(cursor.getColumnIndex("encryption_version"))).isEqualTo(1)
        cursor.close()
    }

    @Test
    fun migration_performance_1000Records_under500ms() {
        // Given: v1 database with 1000 records
        val db = helper.createDatabase(TEST_DB, 1).apply {
            beginTransaction()
            try {
                repeat(1000) { i ->
                    execSQL("""
                        INSERT INTO medications (id, name, dosage, frequency)
                        VALUES ('uuid-$i', 'Med $i', '100mg', 'daily')
                    """)
                }
                setTransactionSuccessful()
            } finally {
                endTransaction()
            }
            close()
        }

        // When: Measure migration time
        val startTime = System.currentTimeMillis()
        helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
        val duration = System.currentTimeMillis() - startTime

        // Then: Migration < 500ms
        assertThat(duration).isLessThan(500)
    }
}
5.1.1.3. PostgreSQL Server Migrations

Server Schema Migration Tests:

-- tests/db/migrations/test_migration_v1_to_v2.sql
BEGIN;
SELECT plan(8);

-- Test: Migration v1 -> v2 (adds encrypted_metadata column)

-- Given: v1 schema with test data
CREATE TABLE srv_encrypted_blobs_v1 (
    blob_id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    entity_type TEXT NOT NULL,
    encrypted_data BYTEA NOT NULL,
    version INT DEFAULT 1,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

INSERT INTO srv_encrypted_blobs_v1 VALUES
    ('blob-1', 'user-1', 'medication', 'encrypted-content-1', 1, NOW()),
    ('blob-2', 'user-1', 'alert', 'encrypted-content-2', 1, NOW());

-- When: Apply migration
ALTER TABLE srv_encrypted_blobs_v1 ADD COLUMN encrypted_metadata JSONB DEFAULT '{}'::jsonb;
UPDATE srv_encrypted_blobs_v1 SET encrypted_metadata = '{"migrated": true}'::jsonb;

-- Then: Verify data preserved
SELECT ok(
    (SELECT COUNT(*) FROM srv_encrypted_blobs_v1) = 2,
    'All records preserved after migration'
);

SELECT ok(
    (SELECT encrypted_data FROM srv_encrypted_blobs_v1 WHERE blob_id = 'blob-1') = 'encrypted-content-1',
    'Encrypted data unchanged'
);

SELECT ok(
    (SELECT encrypted_metadata->>'migrated' FROM srv_encrypted_blobs_v1 WHERE blob_id = 'blob-1') = 'true',
    'New metadata column populated'
);

-- Test: Index creation performance
SELECT ok(
    (SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'srv_encrypted_blobs_v1') >= 1,
    'Indexes created'
);

-- Test: Rollback
SAVEPOINT before_rollback;
DROP TABLE srv_encrypted_blobs_v1;
ROLLBACK TO before_rollback;

SELECT ok(
    (SELECT COUNT(*) FROM srv_encrypted_blobs_v1) = 2,
    'Rollback restores data'
);

SELECT * FROM finish();
ROLLBACK;
5.1.1.4. Migration Test Checklist
  • All existing data preserved
  • New columns/fields populated correctly
  • Default values applied
  • Indexes created/updated
  • Foreign key constraints maintained
  • Rollback tested
  • Performance benchmarked (< 500ms per 1000 records)
  • Migration idempotent (can re-run safely)
5.1.1.5. Migration CI Integration
# .github/workflows/migration-tests.yml
name: Database Migration Tests

on:
  pull_request:
    paths:
      - 'ios/MedTime/Database/Migrations/**'
      - 'android/app/src/main/java/com/medtime/data/migrations/**'
      - 'server/db/migrations/**'

jobs:
  ios-migrations:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Realm Migration Tests
        run: |
          xcodebuild test \
            -scheme MedTime \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -only-testing:MedTimeTests/RealmMigrationTests

  android-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Room Migration Tests
        run: ./gradlew testDebugUnitTest --tests "*RoomMigrationTest"

  postgres-migrations:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - name: Run PostgreSQL Migration Tests
        run: |
          psql -h localhost -U postgres -f tests/db/migrations/*.sql

PostgreSQL con pgTAP:

-- Test RLS: Usuario no puede ver datos de otro usuario
BEGIN;
SELECT plan(3);

-- Setup: Crear dos usuarios
INSERT INTO srv_users (user_id, firebase_uid, role, tier)
VALUES
    ('user-1-uuid', 'firebase-1', 'PI', 'pro'),
    ('user-2-uuid', 'firebase-2', 'PI', 'free');

-- Setup: Crear medicamentos para user-1
INSERT INTO srv_encrypted_blobs (blob_id, user_id, entity_type, encrypted_data)
VALUES ('blob-1', 'user-1-uuid', 'medication', 'encrypted-content');

-- Test 1: User-1 puede ver sus propios datos
SET app.firebase_uid = 'firebase-1';
SELECT ok(
    (SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-1-uuid') = 1,
    'User can see own data'
);

-- Test 2: User-2 NO puede ver datos de User-1
SET app.firebase_uid = 'firebase-2';
SELECT ok(
    (SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-1-uuid') = 0,
    'User cannot see other users data due to RLS'
);

-- Test 3: Sin firebase_uid, no ve nada
SET app.firebase_uid = '';
SELECT ok(
    (SELECT COUNT(*) FROM srv_encrypted_blobs) = 0,
    'No data visible without valid firebase_uid'
);

SELECT * FROM finish();
ROLLBACK;

Cliente SQLite/Realm:

// iOS: Test de migracion de Realm
class RealmMigrationTests: XCTestCase {

    func test_migration_v1_to_v2_preservesData() throws {
        // Given: Database v1
        let v1Config = Realm.Configuration(
            schemaVersion: 1,
            deleteRealmIfMigrationNeeded: false
        )

        let v1Realm = try Realm(configuration: v1Config)
        try v1Realm.write {
            v1Realm.add(MedicationV1(name: "Metformin", dosage: "500mg"))
        }

        // When: Migrate to v2
        let v2Config = Realm.Configuration(
            schemaVersion: 2,
            migrationBlock: MedTimeMigrations.v1ToV2
        )

        let v2Realm = try Realm(configuration: v2Config)

        // Then: Data preserved with new fields
        let medications = v2Realm.objects(MedicationV2.self)
        XCTAssertEqual(medications.count, 1)
        XCTAssertEqual(medications.first?.name, "Metformin")
        XCTAssertNotNil(medications.first?.createdAt) // New field
    }
}

5.2. API Contract Tests

OpenAPI Validation con Prism/Spectral:

# .spectral.yaml - Reglas de validacion
extends: spectral:oas
rules:
  # Todas las respuestas deben tener schema
  operation-success-response:
    severity: error

  # Endpoints de PHI deben requerir auth
  phi-endpoints-require-auth:
    severity: error
    given: "$.paths[*][*]"
    then:
      field: security
      function: truthy

  # No PHI en query params
  no-phi-in-query:
    severity: error
    given: "$.paths[*][*].parameters[?(@.in=='query')]"
    then:
      field: name
      function: pattern
      functionOptions:
        notMatch: "(name|medication|dose|patient)"

5.2.1. Contract Tests por API (DV2-P2)

Agregado: TEST-MEDIO-001 - Cobertura completa de contract tests para todas las APIs.

API Spec File Contract Tests Cobertura
API-SYNC-001 apis/API-SYNC-001.yaml tests/contracts/sync.test.js 100%
API-MED-001 apis/API-MED-001.yaml tests/contracts/medications.test.js 100%
API-RX-001 apis/API-RX-001.yaml tests/contracts/prescriptions.test.js 100%
API-ALT-001 apis/API-ALT-001.yaml tests/contracts/alerts.test.js 100%
API-NTF-001 apis/API-NTF-001.yaml tests/contracts/notifications.test.js 100%
API-EST-001 apis/API-EST-001.yaml tests/contracts/statistics.test.js 100%

Contract Test Template:

// tests/contracts/sync.test.js
import { OpenAPIValidator } from 'express-openapi-validator';
import spec from '../apis/API-SYNC-001.yaml';

describe('API-SYNC-001 Contract Tests', () => {
    const validator = new OpenAPIValidator({ spec });

    describe('POST /sync/push', () => {
        test('request body matches schema', async () => {
            const validBody = {
                changes: [{
                    blob_id: 'uuid-here',
                    entity_type: 'medication',
                    operation: 'upsert',
                    encrypted_data: 'base64-content',
                    version: 1
                }],
                client_timestamp: new Date().toISOString()
            };

            const errors = await validator.validateRequest({
                path: '/sync/push',
                method: 'POST',
                body: validBody
            });

            expect(errors).toHaveLength(0);
        });

        test('batch size limits enforced (1-100)', async () => {
            const tooManyChanges = Array(101).fill({
                blob_id: 'uuid',
                entity_type: 'medication',
                operation: 'upsert',
                encrypted_data: 'data'
            });

            const errors = await validator.validateRequest({
                path: '/sync/push',
                method: 'POST',
                body: { changes: tooManyChanges }
            });

            expect(errors.some(e => e.message.includes('maxItems'))).toBe(true);
        });

        test('response matches 200 schema', async () => {
            const response = {
                sync_token: 'token-123',
                processed: 5,
                conflicts: [],
                server_timestamp: new Date().toISOString()
            };

            const errors = await validator.validateResponse({
                path: '/sync/push',
                method: 'POST',
                status: 200,
                body: response
            });

            expect(errors).toHaveLength(0);
        });
    });

    describe('GET /sync/pull', () => {
        test('query params validated', async () => {
            const errors = await validator.validateRequest({
                path: '/sync/pull',
                method: 'GET',
                query: {
                    since: new Date().toISOString(),
                    entity_types: 'medication,alert'
                }
            });

            expect(errors).toHaveLength(0);
        });

        test('page_size max 100 enforced', async () => {
            const errors = await validator.validateRequest({
                path: '/sync/pull',
                method: 'GET',
                query: { page_size: 150 }
            });

            expect(errors.some(e => e.message.includes('maximum'))).toBe(true);
        });
    });
});

Validacion Automatica en CI:

# .github/workflows/contract-tests.yml
contract-tests:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Validate OpenAPI Specs
      run: |
        npx @stoplight/spectral-cli lint technical-spec/apis/*.yaml
    - name: Run Contract Tests
      run: npm run test:contracts
    - name: Generate Coverage Report
      run: |
        echo "## Contract Test Coverage" >> $GITHUB_STEP_SUMMARY
        echo "| API | Endpoints | Covered |" >> $GITHUB_STEP_SUMMARY
        npm run test:contracts:coverage >> $GITHUB_STEP_SUMMARY

Contract Test con Postman/Newman:

// tests/api/auth.test.js
pm.test("POST /auth/login returns JWT", function () {
    pm.response.to.have.status(200);
    pm.response.to.have.jsonBody("access_token");
    pm.response.to.have.jsonBody("refresh_token");

    // Validate JWT structure
    const jwt = pm.response.json().access_token;
    const parts = jwt.split('.');
    pm.expect(parts.length).to.equal(3);
});

pm.test("Response time < 500ms", function () {
    pm.expect(pm.response.responseTime).to.be.below(500);
});

pm.test("No PHI in response headers", function () {
    pm.response.headers.each((header) => {
        pm.expect(header.value).to.not.include("medication");
        pm.expect(header.value).to.not.include("patient");
    });
});

5.3. Service Integration Tests

// iOS: Test de sincronizacion completa
class SyncServiceIntegrationTests: XCTestCase {

    var syncService: SyncService!
    var localDB: RealmDatabase!
    var mockServer: MockSyncServer!

    override func setUp() async throws {
        localDB = try RealmDatabase.inMemory()
        mockServer = MockSyncServer()
        syncService = SyncService(
            localDB: localDB,
            apiClient: mockServer.client
        )
    }

    func test_fullSync_uploadsLocalChanges_downloadsRemote() async throws {
        // Given: Local changes
        let localMed = Medication.fixture(syncStatus: .pending)
        try localDB.save(localMed)

        // Given: Remote changes
        mockServer.addRemoteBlob(Blob.fixture(entityType: .medication))

        // When
        try await syncService.performFullSync()

        // Then: Local uploaded
        XCTAssertEqual(mockServer.uploadedBlobs.count, 1)

        // Then: Remote downloaded
        let localMeds = try localDB.fetchAll(Medication.self)
        XCTAssertEqual(localMeds.count, 2) // Original + downloaded
    }
}

6. End-to-End Tests

6.1. Critical User Journeys

Journey Prioridad Frecuencia
Registro completo P0 Cada PR
Agregar medicamento P0 Cada PR
Recibir alerta y confirmar toma P0 Cada PR
Configurar escalamiento a cuidador P1 Diario
Activar alerta de emergencia P1 Diario
Sync offline -> online P1 Diario
Ver historial de adherencia P2 Semanal

6.2. iOS UI Tests

// XCUITest: Flujo de agregar medicamento
class AddMedicationE2ETests: XCTestCase {

    var app: XCUIApplication!

    override func setUp() {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting", "--reset-state"]
        app.launch()
    }

    func test_addMedication_fullFlow() {
        // Navigate to add medication
        app.buttons["add_medication_button"].tap()

        // Fill form
        let nameField = app.textFields["medication_name_field"]
        nameField.tap()
        nameField.typeText("Metformina")

        let dosageField = app.textFields["medication_dosage_field"]
        dosageField.tap()
        dosageField.typeText("850mg")

        // Select frequency
        app.buttons["frequency_selector"].tap()
        app.buttons["frequency_twice_daily"].tap()

        // Set times
        app.buttons["time_picker_1"].tap()
        // ... adjust time picker

        // Save
        app.buttons["save_medication_button"].tap()

        // Verify success
        XCTAssertTrue(app.staticTexts["Metformina"].waitForExistence(timeout: 5))
        XCTAssertTrue(app.staticTexts["850mg"].exists)
    }

    func test_addMedication_offline_syncsWhenOnline() {
        // Disable network
        app.launchArguments.append("--offline-mode")
        app.launch()

        // Add medication offline
        addMedicationViaUI(name: "Lisinopril", dosage: "10mg")

        // Verify local save
        XCTAssertTrue(app.staticTexts["Lisinopril"].exists)
        XCTAssertTrue(app.staticTexts["Pending sync"].exists)

        // Re-enable network
        app.terminate()
        app.launchArguments.removeAll { $0 == "--offline-mode" }
        app.launch()

        // Wait for sync
        let syncIndicator = app.staticTexts["Synced"]
        XCTAssertTrue(syncIndicator.waitForExistence(timeout: 10))
    }
}

6.3. Android UI Tests

// Espresso: Flujo de alerta de medicamento
@RunWith(AndroidJUnit4::class)
@LargeTest
class MedicationAlertE2ETest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @get:Rule
    val notificationPermissionRule = GrantPermissionRule.grant(
        Manifest.permission.POST_NOTIFICATIONS
    )

    @Test
    fun alertFlow_userConfirmsTake_updatesHistory() {
        // Given: Medication with scheduled alert
        setupTestMedication(name = "Metformina", scheduledTime = "08:00")

        // When: Alert triggers (simulated)
        triggerAlertNotification(medicationName = "Metformina")

        // Then: Notification appears
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        device.openNotification()

        // User taps "Tomado"
        device.findObject(UiSelector().text("Tomado")).click()

        // Verify: History updated
        onView(withId(R.id.nav_history)).perform(click())
        onView(withText("Metformina")).check(matches(isDisplayed()))
        onView(withText("Tomado")).check(matches(isDisplayed()))
    }
}

7. Security Tests

7.1. Zero-Knowledge Tests

// Test: Servidor no puede leer contenido de blobs
class ZeroKnowledgeTests: XCTestCase {

    func test_serverCannotReadBlobContent() async throws {
        // Given: Encrypted medication blob
        let medication = Medication.fixture(name: "Metformina Secreta")
        let encryptedBlob = try EncryptionService.encrypt(medication)

        // When: Upload to server
        let response = try await apiClient.uploadBlob(encryptedBlob)

        // Then: Server response doesn't contain decrypted content
        XCTAssertNil(response.body.range(of: "Metformina"))
        XCTAssertNil(response.body.range(of: "Secreta"))

        // Then: Server only sees opaque blob
        let serverBlob = try await apiClient.getBlob(encryptedBlob.id)
        XCTAssertEqual(serverBlob.encrypted_data, encryptedBlob.encrypted_data)
    }

    func test_serverLogsDoNotContainPHI() async throws {
        // Given: Request with PHI in encrypted blob
        let blob = createBlobWithPHI()

        // When: Make request
        _ = try await apiClient.uploadBlob(blob)

        // Then: Fetch server logs (test environment)
        let logs = try await fetchServerLogs()

        // Then: No PHI in logs
        XCTAssertFalse(logs.contains("Metformina"))
        XCTAssertFalse(logs.contains("Juan Perez"))
        XCTAssertFalse(logs.contains("diabetes"))
    }
}

7.2. Cifrado E2E Tests

// Test: Roundtrip de cifrado
class EncryptionTests: XCTestCase {

    func test_encryptDecrypt_roundtrip() throws {
        // Given
        let original = "Metformina 850mg - Tomar con desayuno"
        let key = EncryptionService.generateKey()

        // When
        let encrypted = try EncryptionService.encrypt(original, with: key)
        let decrypted = try EncryptionService.decrypt(encrypted, with: key)

        // Then
        XCTAssertEqual(original, decrypted)
        XCTAssertNotEqual(original, String(data: encrypted, encoding: .utf8))
    }

    func test_differentNoncePerOperation() throws {
        // Given
        let data = "Same data"
        let key = EncryptionService.generateKey()

        // When
        let encrypted1 = try EncryptionService.encrypt(data, with: key)
        let encrypted2 = try EncryptionService.encrypt(data, with: key)

        // Then: Different ciphertext due to different nonce
        XCTAssertNotEqual(encrypted1, encrypted2)
    }

    func test_wrongKey_failsDecryption() throws {
        // Given
        let data = "Secret medication"
        let correctKey = EncryptionService.generateKey()
        let wrongKey = EncryptionService.generateKey()

        let encrypted = try EncryptionService.encrypt(data, with: correctKey)

        // When/Then
        XCTAssertThrowsError(
            try EncryptionService.decrypt(encrypted, with: wrongKey)
        ) { error in
            XCTAssertEqual(error as? EncryptionError, .decryptionFailed)
        }
    }

    func test_keyDerivation_consistentWithPassword() throws {
        // Given
        let password = "SecurePassword123!"
        let salt = EncryptionService.generateSalt()

        // When
        let key1 = try EncryptionService.deriveKey(from: password, salt: salt)
        let key2 = try EncryptionService.deriveKey(from: password, salt: salt)

        // Then
        XCTAssertEqual(key1, key2)
    }
}

7.3. RLS Policy Tests

-- Test completo de RLS policies
BEGIN;
SELECT plan(10);

-- Setup
INSERT INTO srv_users VALUES
    ('user-a', 'fb-a', 'PI', 'pro'),
    ('user-b', 'fb-b', 'PI', 'free'),
    ('caregiver-c', 'fb-c', 'CS', 'free');

INSERT INTO srv_user_relations VALUES
    ('rel-1', 'user-a', 'caregiver-c', 'caregiver_solicited', 'active', '{"can_view_medications": true}');

INSERT INTO srv_encrypted_blobs VALUES
    ('blob-a1', 'user-a', 'medication', 'data-a1'),
    ('blob-b1', 'user-b', 'medication', 'data-b1');

-- Test: User A ve sus propios blobs
SET app.firebase_uid = 'fb-a';
SELECT is(
    (SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-a'),
    1::bigint,
    'User A can see own blobs'
);

-- Test: User A NO ve blobs de User B
SELECT is(
    (SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-b'),
    0::bigint,
    'User A cannot see User B blobs'
);

-- Test: Caregiver C puede ver blobs de User A (por relacion)
SET app.firebase_uid = 'fb-c';
SELECT is(
    (SELECT COUNT(*) FROM srv_encrypted_blobs
     WHERE user_id = 'user-a'
     AND EXISTS (
         SELECT 1 FROM srv_user_relations
         WHERE patient_id = 'user-a'
         AND caregiver_id = 'caregiver-c'
         AND status = 'active'
     )),
    1::bigint,
    'Caregiver C can see User A blobs via relation'
);

-- Test: Caregiver C NO ve blobs de User B (sin relacion)
SELECT is(
    (SELECT COUNT(*) FROM srv_encrypted_blobs WHERE user_id = 'user-b'),
    0::bigint,
    'Caregiver C cannot see User B blobs (no relation)'
);

SELECT * FROM finish();
ROLLBACK;

7.4. SAST y DAST

7.4.1. Security Scanning Tools Configuration (DV2-P3)

Agregado: TEST-BAJO-002 - Configuracion completa de herramientas de seguridad automatizadas.

7.4.1.1. SAST (Static Application Security Testing)

Herramientas Configuradas:

Herramienta Lenguaje Integracion Severidad Minima Frecuencia
Snyk Todos GitHub Actions High Cada PR
SonarQube Todos PR checks Medium Cada PR
SwiftLint + Security Swift Xcode + CI Warning Cada build
Detekt + Security Kotlin Gradle + CI Warning Cada build
ESLint + Security JavaScript/Node CI Medium Cada PR
Semgrep Todos CI High Cada PR
GitGuardian Secrets Git hooks + CI Any Cada commit
7.4.1.2. Snyk Configuration
# .snyk (DV2-P3)
version: v1.22.0

# Language-specific settings
language-settings:
  javascript:
    severity-threshold: high
  swift:
    severity-threshold: high
  kotlin:
    severity-threshold: high

# Exclude paths
exclude:
  global:
    - node_modules/**
    - build/**
    - test-fixtures/**

# Patch settings
patch:
  auto-apply: false  # Manual review required
# .github/workflows/snyk-security.yml (DV2-P3)
name: Snyk Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
  schedule:
    - cron: '0 2 * * *'  # Daily at 2 AM

jobs:
  snyk-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Snyk to check for vulnerabilities
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high --all-projects

      - name: Check for Critical/High vulnerabilities
        run: |
          CRITICAL=$(snyk test --json | jq '[.vulnerabilities[] | select(.severity == "critical")] | length')
          HIGH=$(snyk test --json | jq '[.vulnerabilities[] | select(.severity == "high")] | length')

          echo "Critical vulnerabilities: $CRITICAL"
          echo "High vulnerabilities: $HIGH"

          if [ "$CRITICAL" -gt 0 ]; then
            echo "::error::$CRITICAL critical vulnerabilities found"
            exit 1
          fi
7.4.1.3. SonarQube Configuration
# sonar-project.properties (DV2-P3)
sonar.projectKey=medtime-app
sonar.projectName=MedTime
sonar.projectVersion=1.0.0

# Source directories
sonar.sources=ios/MedTime,android/app/src/main,server/functions
sonar.tests=ios/MedTimeTests,android/app/src/test

# Quality Gate thresholds
sonar.qualitygate.wait=true
sonar.security.hotspots.threshold=0
sonar.security.rating=A
sonar.reliability.rating=A
# .github/workflows/sonarqube.yml (DV2-P3)
name: SonarQube Analysis

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  sonarqube:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: SonarQube Scan
        uses: sonarsource/sonarqube-scan-action@master
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

      - name: SonarQube Quality Gate
        uses: sonarsource/sonarqube-quality-gate-action@master
        timeout-minutes: 5
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
7.4.1.4. SwiftLint Security Rules
# .swiftlint.yml (Security Rules - DV2-P3)
opt_in_rules:
  - force_unwrapping
  - legacy_random
  - legacy_hashing
  - weak_delegate

custom_rules:
  no_hardcoded_secrets:
    name: "No Hardcoded Secrets"
    regex: '(password|secret|api[_-]?key|token)\s*=\s*"[^"]+"'
    message: "Hardcoded secrets detected. Use secure storage."
    severity: error

  no_phi_in_logs:
    name: "No PHI in Logs"
    regex: 'log.*\b(medication|patient|prescription)\b'
    message: "Potential PHI in logs. Ensure data is encrypted."
    severity: warning
7.4.1.5. Detekt Security Rules
# detekt-config.yml (Security Rules - DV2-P3)
security:
  active: true
  HardcodedPassword:
    active: true
  InsecureCryptoAlgorithm:
    active: true
  WeakRandom:
    active: true
    severity: error

custom-rules:
  NoHardcodedSecrets:
    active: true
    pattern: '(password|secret|apiKey|token)\s*=\s*"[^"]+"'
    severity: error

  NoPhiInLogs:
    active: true
    pattern: 'log.*\b(medication|patient|prescription)\b'
    severity: warning
7.4.1.6. Semgrep Configuration
# .semgrep.yml (DV2-P3)
rules:
  - id: hardcoded-secret
    patterns:
      - pattern-either:
          - pattern: password = "..."
          - pattern: api_key = "..."
          - pattern: secret = "..."
    message: Hardcoded secret detected
    severity: ERROR
    languages: [swift, kotlin, javascript]

  - id: weak-crypto
    patterns:
      - pattern-either:
          - pattern: MD5(...)
          - pattern: SHA1(...)
    message: Weak cryptographic algorithm. Use SHA-256 or better.
    severity: ERROR
    languages: [swift, kotlin, javascript]

  - id: phi-in-logs
    patterns:
      - pattern: log($PHI)
      - metavariable-regex:
          metavariable: $PHI
          regex: .*(medication|patient).*
    message: Potential PHI in logs
    severity: WARNING
    languages: [swift, kotlin, javascript]
7.4.1.7. GitGuardian Configuration
# .gitguardian.yml (DV2-P3)
version: 2

paths-ignore:
  - "**/*.md"
  - "**/test-fixtures/**"

minimum-severity: low

detectors:
  - name: "AWS"
    enabled: true
  - name: "Generic Password"
    enabled: true
  - name: "API Keys"
    enabled: true

exit-zero: false  # Fail on secrets found
7.4.1.8. DAST (Dynamic Application Security Testing)

Herramientas Configuradas:

Herramienta Tipo Frecuencia Severidad Minima
OWASP ZAP API scanning Semanal Medium
MobSF Mobile app scanning Por release High
Burp Suite Manual testing Trimestral High
Nuclei Vulnerability scanning Semanal High
7.4.1.9. OWASP ZAP Configuration
# .github/workflows/owasp-zap.yml (DV2-P3)
name: OWASP ZAP Scan

on:
  schedule:
    - cron: '0 3 * * 1'  # Weekly on Monday
  workflow_dispatch:

jobs:
  zap-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start test environment
        run: docker-compose -f docker-compose.test.yml up -d

      - name: ZAP API Scan
        uses: zaproxy/action-api-scan@v0.4.0
        with:
          target: 'http://localhost:3000/api-definition.yaml'
          rules_file_name: 'zap-rules.tsv'

      - name: Check for High/Critical findings
        run: |
          CRITICAL=$(cat report_json.json | jq '[.site[].alerts[] | select(.riskcode == "3")] | length')
          if [ "$CRITICAL" -gt 0 ]; then
            echo "::error::$CRITICAL critical vulnerabilities found"
            exit 1
          fi
7.4.1.10. MobSF Configuration
# .github/workflows/mobsf-scan.yml (DV2-P3)
name: MobSF Mobile Security Scan

on:
  release:
    types: [published]
  workflow_dispatch:

jobs:
  mobsf-ios:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build iOS .ipa
        run: xcodebuild archive -scheme MedTime ...

      - name: MobSF Scan
        uses: fundacaocerti/mobsf-action@v1.3
        with:
          INPUT_FILE_NAME: 'MedTime.ipa'
          SCAN_TYPE: 'ipa'
          MOBSF_URL: ${{ secrets.MOBSF_URL }}
          MOBSF_API_KEY: ${{ secrets.MOBSF_API_KEY }}

      - name: Check Security Score
        run: |
          SCORE=$(cat mobsf-report.json | jq '.security_score')
          if (( $(echo "$SCORE < 70" | bc -l) )); then
            echo "::error::Security score too low: $SCORE"
            exit 1
          fi
7.4.1.11. Security Scanning Metrics
Metric Target Alert Threshold
Critical vulnerabilities 0 Any found
High vulnerabilities 0 > 0
Medium vulnerabilities < 5 > 10
MobSF Security Score > 80 < 70
SonarQube Security Rating A B or lower
Secrets detected 0 Any found

7.5. Penetration Testing

Tipo Frecuencia Scope
Automated Continuo (CI) OWASP Top 10
Manual Trimestral Full application
Red Team Anual Infrastructure + App

Checklist de Penetration Test:

  • Authentication bypass attempts
  • RLS circumvention attempts
  • Blob decryption without key
  • SQL injection on server
  • API rate limiting bypass
  • JWT manipulation
  • MITM attack resistance

8. Performance Tests

8.1. Performance Baselines (DV2-P2)

Agregado: TEST-MEDIO-002 - Baselines establecidos para todas las operaciones criticas.

8.1.1. Baseline Methodology

flowchart LR
    subgraph Establecimiento["BASELINE ESTABLISHMENT"]
        M[Medicion Inicial] --> A[Analisis Estadistico]
        A --> B[Baseline + Tolerancia]
        B --> R[Registro en CI]
    end

    subgraph Monitoreo["REGRESSION DETECTION"]
        R --> C[Comparacion Continua]
        C --> |Regresion > 10%| AL[Alerta]
        C --> |OK| PASS[Pass]
    end

8.1.2. Baselines por Operacion

Categoria Operacion Baseline P50 Baseline P99 Tolerancia Critico
Startup Cold launch iOS 1.5s 2.5s +15% +50%
Startup Cold launch Android 1.8s 2.8s +15% +50%
Startup Warm launch 400ms 800ms +20% +100%
Crypto Encrypt blob (1KB) 35ms 80ms +10% +100%
Crypto Decrypt blob (1KB) 30ms 70ms +10% +100%
Crypto Key derivation (Argon2) 200ms 350ms +10% +50%
DB Insert medication 15ms 40ms +20% +150%
DB Query medications (100) 80ms 150ms +15% +100%
DB Full-text search 50ms 120ms +20% +150%
Sync Push 10 blobs 500ms 1.2s +20% +100%
Sync Pull 50 blobs 800ms 2s +20% +100%
Sync Conflict resolution 100ms 250ms +15% +100%
UI List render (100 items) 16ms 32ms +20% +100%
UI Form validation 5ms 15ms +25% +200%
Notification Schedule alert 8ms 25ms +25% +200%
Notification Cancel alert 5ms 15ms +25% +200%

8.1.3. Baseline Testing Implementation

// iOS: Performance Baseline Tests
class PerformanceBaselineTests: XCTestCase {

    // MARK: - Baselines (DV2-P2)
    private let baselines: [String: (p50: Double, p99: Double, tolerance: Double)] = [
        "encrypt_1kb": (p50: 0.035, p99: 0.080, tolerance: 0.10),
        "decrypt_1kb": (p50: 0.030, p99: 0.070, tolerance: 0.10),
        "db_insert": (p50: 0.015, p99: 0.040, tolerance: 0.20),
        "db_query_100": (p50: 0.080, p99: 0.150, tolerance: 0.15)
    ]

    func test_encrypt_baseline() {
        let baseline = baselines["encrypt_1kb"]!
        let data = Data(repeating: 0x42, count: 1024)

        let metrics = measureWithMetrics([XCTClockMetric()]) {
            _ = try? EncryptionService.shared.encrypt(data)
        }

        // Verify against baseline
        let avgTime = metrics.averageValue(for: XCTClockMetric.self)
        XCTAssertLessThan(
            avgTime,
            baseline.p50 * (1 + baseline.tolerance),
            "Encryption exceeded baseline by more than \(baseline.tolerance * 100)%"
        )
    }

    func test_db_query_baseline() {
        let baseline = baselines["db_query_100"]!

        // Setup: Insert 100 medications
        try? setupTestMedications(count: 100)

        let metrics = measureWithMetrics([XCTClockMetric()]) {
            _ = try? repository.fetchAll()
        }

        XCTAssertLessThan(
            metrics.averageValue(for: XCTClockMetric.self),
            baseline.p50 * (1 + baseline.tolerance)
        )
    }
}
// Android: Performance Baseline Tests
@RunWith(AndroidJUnit4::class)
class PerformanceBaselineTest {

    companion object {
        // Baselines (DV2-P2)
        val BASELINES = mapOf(
            "encrypt_1kb" to Baseline(p50 = 35.0, p99 = 80.0, tolerance = 0.10),
            "decrypt_1kb" to Baseline(p50 = 30.0, p99 = 70.0, tolerance = 0.10),
            "db_insert" to Baseline(p50 = 15.0, p99 = 40.0, tolerance = 0.20),
            "db_query_100" to Baseline(p50 = 80.0, p99 = 150.0, tolerance = 0.15)
        )
    }

    @get:Rule
    val benchmarkRule = BenchmarkRule()

    @Test
    fun encrypt_baseline() {
        val baseline = BASELINES["encrypt_1kb"]!!
        val data = ByteArray(1024) { 0x42 }

        benchmarkRule.measureRepeated {
            runWithTimingDisabled { /* setup */ }
            encryptionService.encrypt(data)
        }

        val avgMs = benchmarkRule.getMetrics().metrics["timeNs"]!! / 1_000_000.0
        assertThat(avgMs).isLessThan(baseline.p50 * (1 + baseline.tolerance))
    }

    data class Baseline(val p50: Double, val p99: Double, val tolerance: Double)
}

8.1.4. CI Regression Detection

# .github/workflows/performance-baselines.yml
name: Performance Baseline Check

on:
  pull_request:
    paths:
      - 'ios/**'
      - 'android/**'

jobs:
  ios-performance:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Performance Tests
        run: |
          xcodebuild test \
            -scheme MedTime-Performance \
            -destination 'platform=iOS Simulator,name=iPhone 15'
      - name: Compare with Baselines
        run: |
          python scripts/compare_baselines.py \
            --current test-results/performance.json \
            --baselines baselines/ios.json \
            --threshold 0.10

  android-performance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Benchmark Tests
        run: ./gradlew :benchmark:connectedCheck
      - name: Compare with Baselines
        run: |
          python scripts/compare_baselines.py \
            --current benchmark/results.json \
            --baselines baselines/android.json \
            --threshold 0.10

8.2. Benchmarks Cliente

Operacion Target Critico Herramienta
App launch (cold) < 2s < 3s Instruments/Android Profiler
App launch (warm) < 500ms < 1s Instruments
Load medication list (100 items) < 100ms < 200ms XCTest measure
Encrypt medication blob < 50ms < 100ms Unit test
Decrypt medication blob < 50ms < 100ms Unit test
Schedule notification < 10ms < 50ms Unit test
Local DB query < 20ms < 50ms Integration test
// iOS: Performance test de cifrado
func test_encryptMedication_performance() {
    let medication = Medication.fixture()

    measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
        _ = try? EncryptionService.encrypt(medication)
    }
}

8.3. Benchmarks Servidor

Endpoint Target P50 Target P99 Max
POST /auth/login 200ms 500ms 1s
GET /sync/pull 300ms 800ms 2s
POST /sync/push 300ms 800ms 2s
GET /catalog/drugs (search) 100ms 300ms 500ms
POST /alerts/emergency 100ms 200ms 500ms

8.4. Load Testing

8.4.1. Load Testing Scenarios (DV2-P3)

Agregado: TEST-BAJO-003 - Scenarios realistas de load testing para todos los endpoints criticos.

8.4.1.1. Load Test Strategy
flowchart TD
    subgraph Scenarios["LOAD TEST SCENARIOS"]
        SMOKE[Smoke Test<br/>1-5 users]
        LOAD[Load Test<br/>Expected load]
        STRESS[Stress Test<br/>Beyond capacity]
        SPIKE[Spike Test<br/>Sudden surge]
        SOAK[Soak Test<br/>Extended duration]
    end

    SMOKE --> LOAD
    LOAD --> STRESS
    LOAD --> SPIKE
    LOAD --> SOAK

    style SMOKE fill:#99ff99
    style LOAD fill:#99ccff
    style STRESS fill:#ffcc99
    style SPIKE fill:#ff9999
    style SOAK fill:#cc99ff
8.4.1.2. Scenario 1: Smoke Test (Sanity Check)

Objetivo: Verificar que el sistema funciona bajo carga minima.

// load-tests/smoke-test.js (DV2-P3)
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
    vus: 1,                  // 1 usuario virtual
    duration: '1m',          // 1 minuto
    thresholds: {
        http_req_duration: ['p(99)<500'],  // 99% < 500ms
        errors: ['rate<0.01'],              // < 1% errors
    },
};

export default function () {
    const BASE_URL = __ENV.BASE_URL || 'https://api.medtime.app';
    const token = getAuthToken();

    // Test critical endpoints
    testAuth(BASE_URL);
    testSyncPull(BASE_URL, token);
    testSyncPush(BASE_URL, token);
    testCatalogSearch(BASE_URL, token);

    sleep(1);
}

function testAuth(baseUrl) {
    const res = http.post(`${baseUrl}/auth/login`, JSON.stringify({
        email: 'test@medtime.app',
        password: 'test-password'
    }), {
        headers: { 'Content-Type': 'application/json' }
    });

    const success = check(res, {
        'auth: status 200': (r) => r.status === 200,
        'auth: has token': (r) => r.json('access_token') !== null,
        'auth: time < 500ms': (r) => r.timings.duration < 500,
    });

    errorRate.add(!success);
}
8.4.1.3. Scenario 2: Load Test (Expected Load)

Objetivo: Simular carga esperada durante uso normal.

Assumptions:

  • 10,000 usuarios activos diarios
  • 20% usan la app simultaneamente en pico (2,000 usuarios)
  • Cada usuario hace 10 operaciones por sesion
  • Sesion promedio: 5 minutos
// load-tests/load-test.js (DV2-P3)
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Counter, Trend } from 'k6/metrics';

const syncPullDuration = new Trend('sync_pull_duration');
const syncPushDuration = new Trend('sync_push_duration');
const errorCounter = new Counter('errors');

export const options = {
    stages: [
        { duration: '5m', target: 500 },   // Ramp up to 500 VUs
        { duration: '10m', target: 1000 }, // Ramp to 1000 VUs
        { duration: '15m', target: 2000 }, // Peak load: 2000 VUs
        { duration: '10m', target: 1000 }, // Ramp down to 1000
        { duration: '5m', target: 0 },     // Ramp down to 0
    ],
    thresholds: {
        http_req_duration: ['p(95)<1000', 'p(99)<2000'],
        http_req_failed: ['rate<0.01'],
        sync_pull_duration: ['p(95)<800'],
        sync_push_duration: ['p(95)<800'],
        errors: ['count<100'],
    },
};

export default function () {
    const BASE_URL = __ENV.BASE_URL;
    const userId = `user-${__VU}`;
    const token = authenticateUser(userId);

    // Realistic user session
    group('User Session', function() {
        // 1. Pull latest data
        group('Sync Pull', function() {
            const startTime = Date.now();
            const res = http.get(`${BASE_URL}/sync/pull?since=${getLastSyncTime()}`, {
                headers: { 'Authorization': `Bearer ${token}` }
            });

            const success = check(res, {
                'pull: status 200': (r) => r.status === 200,
                'pull: has changes': (r) => r.json('changes') !== null,
            });

            syncPullDuration.add(Date.now() - startTime);
            if (!success) errorCounter.add(1);
        });

        sleep(2);

        // 2. View medications
        group('View Medications', function() {
            const res = http.get(`${BASE_URL}/medications`, {
                headers: { 'Authorization': `Bearer ${token}` }
            });

            check(res, {
                'medications: status 200': (r) => r.status === 200,
            });
        });

        sleep(5);

        // 3. Add/Update medication (20% of users)
        if (Math.random() < 0.2) {
            group('Update Medication', function() {
                const startTime = Date.now();
                const res = http.post(`${BASE_URL}/sync/push`, JSON.stringify({
                    changes: [generateMedicationBlob()]
                }), {
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json'
                    }
                });

                const success = check(res, {
                    'push: status 200': (r) => r.status === 200,
                    'push: processed': (r) => r.json('processed') > 0,
                });

                syncPushDuration.add(Date.now() - startTime);
                if (!success) errorCounter.add(1);
            });

            sleep(3);
        }

        // 4. Search drug catalog (30% of users)
        if (Math.random() < 0.3) {
            group('Search Catalog', function() {
                const res = http.get(`${BASE_URL}/catalog/drugs?q=metformin`, {
                    headers: { 'Authorization': `Bearer ${token}` }
                });

                check(res, {
                    'catalog: status 200': (r) => r.status === 200,
                    'catalog: has results': (r) => r.json('results').length > 0,
                });
            });

            sleep(2);
        }

        // 5. View alerts (50% of users)
        if (Math.random() < 0.5) {
            group('View Alerts', function() {
                const res = http.get(`${BASE_URL}/alerts`, {
                    headers: { 'Authorization': `Bearer ${token}` }
                });

                check(res, {
                    'alerts: status 200': (r) => r.status === 200,
                });
            });

            sleep(3);
        }
    });

    sleep(Math.random() * 10 + 5);  // Random think time 5-15s
}
8.4.1.4. Scenario 3: Stress Test (Beyond Capacity)

Objetivo: Encontrar el punto de quiebre del sistema.

// load-tests/stress-test.js (DV2-P3)
export const options = {
    stages: [
        { duration: '2m', target: 100 },   // Warm up
        { duration: '5m', target: 1000 },  // Expected load
        { duration: '5m', target: 2000 },  // Beyond expected
        { duration: '5m', target: 3000 },  // Stress
        { duration: '5m', target: 4000 },  // More stress
        { duration: '5m', target: 5000 },  // Maximum stress
        { duration: '5m', target: 0 },     // Recovery
    ],
    thresholds: {
        http_req_duration: ['p(99)<5000'],  // Allow higher latency
        http_req_failed: ['rate<0.05'],     // Allow 5% errors
    },
};

export default function () {
    // Same as load test but observe degradation
    const BASE_URL = __ENV.BASE_URL;
    const token = getAuthToken();

    const res = http.get(`${BASE_URL}/sync/pull`, {
        headers: { 'Authorization': `Bearer ${token}` }
    });

    check(res, {
        'status not 5xx': (r) => r.status < 500,  // No server crashes
        'response time tracked': (r) => r.timings.duration > 0,
    });

    sleep(1);
}
8.4.1.5. Scenario 4: Spike Test (Sudden Traffic Surge)

Objetivo: Probar comportamiento ante picos repentinos (notificacion masiva, lanzamiento).

// load-tests/spike-test.js (DV2-P3)
export const options = {
    stages: [
        { duration: '1m', target: 100 },   // Normal load
        { duration: '30s', target: 3000 }, // Sudden spike!
        { duration: '2m', target: 3000 },  // Hold spike
        { duration: '1m', target: 100 },   // Return to normal
        { duration: '2m', target: 100 },   // Sustained normal
    ],
    thresholds: {
        http_req_duration: ['p(99)<3000'],  // Some degradation OK
        http_req_failed: ['rate<0.02'],     // < 2% errors
    },
};

export default function () {
    const BASE_URL = __ENV.BASE_URL;
    const token = getAuthToken();

    // Simulate morning alert rush (8 AM)
    const res = http.post(`${BASE_URL}/alerts/confirm`, JSON.stringify({
        alert_id: generateAlertId(),
        status: 'taken'
    }), {
        headers: {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json'
        }
    });

    check(res, {
        'alert confirmed': (r) => r.status === 200,
        'no timeout': (r) => r.timings.duration < 5000,
    });

    sleep(0.5);  // Shorter sleep during spike
}
8.4.1.6. Scenario 5: Soak Test (Endurance)

Objetivo: Detectar memory leaks y degradacion en el tiempo.

// load-tests/soak-test.js (DV2-P3)
export const options = {
    stages: [
        { duration: '5m', target: 500 },     // Ramp up
        { duration: '3h', target: 500 },     // Sustained load: 3 hours
        { duration: '5m', target: 0 },       // Ramp down
    ],
    thresholds: {
        http_req_duration: ['p(99)<2000'],   // No degradation over time
        http_req_failed: ['rate<0.01'],
    },
};

export default function () {
    const BASE_URL = __ENV.BASE_URL;
    const token = getAuthToken();

    // Standard user operations
    http.get(`${BASE_URL}/sync/pull`, {
        headers: { 'Authorization': `Bearer ${token}` }
    });

    sleep(5);

    http.post(`${BASE_URL}/sync/push`, JSON.stringify({
        changes: [generateTestBlob()]
    }), {
        headers: {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json'
        }
    });

    sleep(10);
}
8.4.1.7. Scenario 6: Concurrent Medication Updates (Race Conditions)

Objetivo: Probar conflict resolution bajo alta concurrencia.

// load-tests/concurrent-updates-test.js (DV2-P3)
export const options = {
    scenarios: {
        concurrent_updates: {
            executor: 'constant-vus',
            vus: 50,
            duration: '5m',
        },
    },
    thresholds: {
        'conflicts_detected': ['rate>0.1'],  // Expect conflicts
        'conflicts_resolved': ['rate>0.99'], // 99% resolved correctly
    },
};

export default function () {
    const BASE_URL = __ENV.BASE_URL;
    const token = getAuthToken();
    const medicationId = 'shared-med-123';  // Same medication updated by all VUs

    // Concurrent update to same medication
    const res = http.post(`${BASE_URL}/sync/push`, JSON.stringify({
        changes: [{
            blob_id: medicationId,
            entity_type: 'medication',
            operation: 'upsert',
            encrypted_data: generateEncryptedData(),
            version: Math.floor(Math.random() * 10) + 1  // Random version
        }]
    }), {
        headers: {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json'
        }
    });

    check(res, {
        'update processed': (r) => r.status === 200 || r.status === 409,
        'conflict handled': (r) => {
            if (r.status === 409) {
                // Conflict detected and returned
                return r.json('conflicts').length > 0;
            }
            return true;
        },
    });

    sleep(0.5);
}
8.4.1.8. Load Test Execution Matrix
Scenario Frequency Duration Target VUs Threshold P99 Alerts On
Smoke Each PR 1 min 1-5 500ms Failure
Load Nightly 45 min 2,000 2s >5% errors
Stress Weekly 35 min 5,000 5s Server crash
Spike Weekly 6.5 min 3,000 3s >2% errors
Soak Weekly 3h 10min 500 2s Memory leak
Concurrent Daily 5 min 50 1s Data corruption
8.4.1.9. Load Test CI Integration
# .github/workflows/load-tests.yml (DV2-P3)
name: Load Tests

on:
  schedule:
    - cron: '0 2 * * *'  # Nightly at 2 AM
  workflow_dispatch:
    inputs:
      scenario:
        description: 'Test scenario'
        required: true
        type: choice
        options:
          - smoke
          - load
          - stress
          - spike
          - soak
          - concurrent

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup k6
        run: |
          curl https://github.com/grafana/k6/releases/download/v0.48.0/k6-v0.48.0-linux-amd64.tar.gz -L | tar xvz
          sudo mv k6-v0.48.0-linux-amd64/k6 /usr/local/bin/

      - name: Run Load Test
        env:
          BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL }}
          K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
        run: |
          SCENARIO=${{ github.event.inputs.scenario || 'load' }}
          k6 run --out cloud load-tests/${SCENARIO}-test.js

      - name: Check Results
        run: |
          # Parse k6 output
          FAILURES=$(cat k6-results.json | jq '.metrics.http_req_failed.values.rate')
          P99=$(cat k6-results.json | jq '.metrics.http_req_duration.values["p(99)"]')

          echo "Failure rate: $FAILURES"
          echo "P99 latency: ${P99}ms"

          if (( $(echo "$FAILURES > 0.05" | bc -l) )); then
            echo "::error::Failure rate too high: $FAILURES"
            exit 1
          fi

      - name: Upload Results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: load-test-results
          path: |
            k6-results.json
            k6-summary.html
8.4.1.10. Load Test Monitoring Dashboard
// Grafana dashboard queries for k6 metrics
const k6Queries = {
    responseTime: 'avg(k6_http_req_duration)',
    throughput: 'rate(k6_http_reqs_total[5m])',
    errorRate: 'rate(k6_http_req_failed[5m])',
    activeUsers: 'k6_vus',
};
8.4.1.11. Expected Results & SLOs
Metric Target Warning Critical
P50 Latency < 300ms > 500ms > 1s
P95 Latency < 1s > 1.5s > 2s
P99 Latency < 2s > 3s > 5s
Error Rate < 0.1% > 1% > 5%
Throughput > 100 req/s < 50 req/s < 20 req/s
Concurrent Users 2,000 1,000 500

9. Offline-First Tests

9.1. Escenarios Offline

Escenario Test Validacion
Sin conexion desde inicio E2E App funciona, datos locales
Conexion perdida mid-session Integration Operaciones continuan
Reconexion Integration Sync automatico
Offline por 7 dias E2E Alertas funcionan
// Test: Alertas funcionan offline
func test_alertsFunctionOffline() async throws {
    // Given: Disable network
    NetworkSimulator.setOffline()

    // Given: Scheduled medication alert
    let alert = Alert.fixture(scheduledFor: Date().addingTimeInterval(5))
    try alertService.schedule(alert)

    // When: Wait for alert time
    try await Task.sleep(nanoseconds: 6_000_000_000) // 6 seconds

    // Then: Local notification triggered
    let notifications = await notificationCenter.pendingNotifications()
    XCTAssertTrue(notifications.contains { $0.id == alert.id })
}

9.2. Sync Conflict Tests

// Test: Resolucion de conflictos
func test_syncConflict_lastWriteWins() async throws {
    // Given: Local version
    let localMed = Medication.fixture(name: "Local Name", version: 2)
    try localDB.save(localMed)

    // Given: Server version (older)
    let serverMed = Medication.fixture(id: localMed.id, name: "Server Name", version: 1)
    mockServer.setRemoteData([serverMed])

    // When: Sync
    try await syncService.performSync()

    // Then: Local wins (higher version)
    let result = try localDB.fetch(Medication.self, id: localMed.id)
    XCTAssertEqual(result?.name, "Local Name")
    XCTAssertEqual(result?.version, 2)
}

func test_syncConflict_serverWins_whenNewer() async throws {
    // Given: Local version (older)
    let localMed = Medication.fixture(name: "Local Name", version: 1)
    try localDB.save(localMed)

    // Given: Server version (newer)
    let serverMed = Medication.fixture(id: localMed.id, name: "Server Name", version: 3)
    mockServer.setRemoteData([serverMed])

    // When: Sync
    try await syncService.performSync()

    // Then: Server wins
    let result = try localDB.fetch(Medication.self, id: localMed.id)
    XCTAssertEqual(result?.name, "Server Name")
    XCTAssertEqual(result?.version, 3)
}

10. Test Data Management

10.1. Fixtures y Factories

10.1.1. Fixture Library Completa (DV2-P2)

Agregado: TEST-MEDIO-003 - Fixtures para todas las entidades del sistema.

Entidad iOS Fixture Android Fixture Variantes
User User.fixture() UserFixture.create() PI, CS, Admin
Medication Medication.fixture() MedicationFixture.create() Active, Inactive, Expired
Alert Alert.fixture() AlertFixture.create() Scheduled, Triggered, Snoozed
Prescription Prescription.fixture() PrescriptionFixture.create() Active, Completed, Cancelled
EncryptedBlob EncryptedBlob.fixture() EncryptedBlobFixture.create() All entity types
SyncChange SyncChange.fixture() SyncChangeFixture.create() Upsert, Delete
Notification Notification.fixture() NotificationFixture.create() Push, Local
UserRelation UserRelation.fixture() UserRelationFixture.create() Caregiver, Family
AdherenceRecord AdherenceRecord.fixture() AdherenceRecordFixture.create() Taken, Missed, Skipped
HealthEvent HealthEvent.fixture() HealthEventFixture.create() Vital, Symptom, Note
// iOS: Fixture Factory
extension Medication {
    static func fixture(
        id: UUID = UUID(),
        name: String = "Test Medication",
        dosage: String = "100mg",
        frequency: Frequency = .daily,
        syncStatus: SyncStatus = .synced,
        version: Int = 1
    ) -> Medication {
        Medication(
            id: id,
            name: name,
            dosage: dosage,
            frequency: frequency,
            syncStatus: syncStatus,
            version: version,
            createdAt: Date(),
            updatedAt: Date()
        )
    }
}

// Kotlin: Fixture Factory
object MedicationFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        name: String = "Test Medication",
        dosage: String = "100mg",
        frequency: Frequency = Frequency.DAILY,
        syncStatus: SyncStatus = SyncStatus.SYNCED,
        version: Int = 1
    ) = Medication(
        id = id,
        name = name,
        dosage = dosage,
        frequency = frequency,
        syncStatus = syncStatus,
        version = version,
        createdAt = Instant.now(),
        updatedAt = Instant.now()
    )
}

10.1.2. Fixtures Adicionales (DV2-P2)

// iOS: Fixtures completos para todas las entidades

// MARK: - User Fixtures
extension User {
    static func fixture(
        id: UUID = UUID(),
        email: String = "test@medtime.app",
        role: UserRole = .patientIndependent,
        tier: SubscriptionTier = .free,
        firebaseUid: String = "firebase-test-uid"
    ) -> User {
        User(id: id, email: email, role: role, tier: tier, firebaseUid: firebaseUid)
    }

    static func caregiverFixture() -> User {
        fixture(role: .caregiverSolicited, email: "caregiver@medtime.app")
    }
}

// MARK: - Alert Fixtures
extension Alert {
    static func fixture(
        id: UUID = UUID(),
        medicationId: UUID = UUID(),
        scheduledTime: Date = Date().addingTimeInterval(3600),
        status: AlertStatus = .scheduled,
        snoozeCount: Int = 0
    ) -> Alert {
        Alert(
            id: id,
            medicationId: medicationId,
            scheduledTime: scheduledTime,
            status: status,
            snoozeCount: snoozeCount
        )
    }

    static func triggeredFixture() -> Alert {
        fixture(scheduledTime: Date().addingTimeInterval(-60), status: .triggered)
    }

    static func snoozedFixture() -> Alert {
        fixture(status: .snoozed, snoozeCount: 1)
    }
}

// MARK: - Prescription Fixtures
extension Prescription {
    static func fixture(
        id: UUID = UUID(),
        doctorName: String = "Dr. Test",
        rxNumber: String = "RX-123456",
        medications: [UUID] = [],
        status: PrescriptionStatus = .active,
        expiresAt: Date = Date().addingTimeInterval(86400 * 30)
    ) -> Prescription {
        Prescription(
            id: id,
            doctorName: doctorName,
            rxNumber: rxNumber,
            medications: medications,
            status: status,
            expiresAt: expiresAt
        )
    }
}

// MARK: - EncryptedBlob Fixtures
extension EncryptedBlob {
    static func fixture(
        id: UUID = UUID(),
        entityType: EntityType = .medication,
        encryptedData: Data = Data("encrypted-test-content".utf8),
        checksum: String = "sha256-test-checksum",
        version: Int = 1
    ) -> EncryptedBlob {
        EncryptedBlob(
            id: id,
            entityType: entityType,
            encryptedData: encryptedData,
            checksum: checksum,
            version: version
        )
    }

    static func medicationBlob() -> EncryptedBlob {
        fixture(entityType: .medication)
    }

    static func alertBlob() -> EncryptedBlob {
        fixture(entityType: .alert)
    }
}

// MARK: - SyncChange Fixtures
extension SyncChange {
    static func fixture(
        blobId: UUID = UUID(),
        operation: SyncOperation = .upsert,
        entityType: EntityType = .medication,
        timestamp: Date = Date()
    ) -> SyncChange {
        SyncChange(
            blobId: blobId,
            operation: operation,
            entityType: entityType,
            timestamp: timestamp
        )
    }

    static func deleteFixture() -> SyncChange {
        fixture(operation: .delete)
    }
}

// MARK: - AdherenceRecord Fixtures
extension AdherenceRecord {
    static func fixture(
        id: UUID = UUID(),
        medicationId: UUID = UUID(),
        alertId: UUID = UUID(),
        status: AdherenceStatus = .taken,
        recordedAt: Date = Date()
    ) -> AdherenceRecord {
        AdherenceRecord(
            id: id,
            medicationId: medicationId,
            alertId: alertId,
            status: status,
            recordedAt: recordedAt
        )
    }

    static func missedFixture() -> AdherenceRecord {
        fixture(status: .missed)
    }

    static func skippedFixture() -> AdherenceRecord {
        fixture(status: .skipped)
    }
}

// MARK: - UserRelation Fixtures
extension UserRelation {
    static func fixture(
        id: UUID = UUID(),
        patientId: UUID = UUID(),
        caregiverId: UUID = UUID(),
        type: RelationType = .caregiverSolicited,
        status: RelationStatus = .active,
        permissions: CaregiverPermissions = .default
    ) -> UserRelation {
        UserRelation(
            id: id,
            patientId: patientId,
            caregiverId: caregiverId,
            type: type,
            status: status,
            permissions: permissions
        )
    }
}
// Android: Fixtures completos para todas las entidades

// User Fixtures
object UserFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        email: String = "test@medtime.app",
        role: UserRole = UserRole.PATIENT_INDEPENDENT,
        tier: SubscriptionTier = SubscriptionTier.FREE,
        firebaseUid: String = "firebase-test-uid"
    ) = User(id, email, role, tier, firebaseUid)

    fun caregiver() = create(
        role = UserRole.CAREGIVER_SOLICITED,
        email = "caregiver@medtime.app"
    )
}

// Alert Fixtures
object AlertFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        medicationId: UUID = UUID.randomUUID(),
        scheduledTime: Instant = Instant.now().plusSeconds(3600),
        status: AlertStatus = AlertStatus.SCHEDULED,
        snoozeCount: Int = 0
    ) = Alert(id, medicationId, scheduledTime, status, snoozeCount)

    fun triggered() = create(
        scheduledTime = Instant.now().minusSeconds(60),
        status = AlertStatus.TRIGGERED
    )

    fun snoozed() = create(status = AlertStatus.SNOOZED, snoozeCount = 1)
}

// Prescription Fixtures
object PrescriptionFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        doctorName: String = "Dr. Test",
        rxNumber: String = "RX-123456",
        medications: List<UUID> = emptyList(),
        status: PrescriptionStatus = PrescriptionStatus.ACTIVE
    ) = Prescription(id, doctorName, rxNumber, medications, status)
}

// EncryptedBlob Fixtures
object EncryptedBlobFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        entityType: EntityType = EntityType.MEDICATION,
        encryptedData: ByteArray = "encrypted-test-content".toByteArray(),
        checksum: String = "sha256-test-checksum",
        version: Int = 1
    ) = EncryptedBlob(id, entityType, encryptedData, checksum, version)

    fun medication() = create(entityType = EntityType.MEDICATION)
    fun alert() = create(entityType = EntityType.ALERT)
}

// SyncChange Fixtures
object SyncChangeFixture {
    fun create(
        blobId: UUID = UUID.randomUUID(),
        operation: SyncOperation = SyncOperation.UPSERT,
        entityType: EntityType = EntityType.MEDICATION,
        timestamp: Instant = Instant.now()
    ) = SyncChange(blobId, operation, entityType, timestamp)

    fun delete() = create(operation = SyncOperation.DELETE)
}

// AdherenceRecord Fixtures
object AdherenceRecordFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        medicationId: UUID = UUID.randomUUID(),
        alertId: UUID = UUID.randomUUID(),
        status: AdherenceStatus = AdherenceStatus.TAKEN,
        recordedAt: Instant = Instant.now()
    ) = AdherenceRecord(id, medicationId, alertId, status, recordedAt)

    fun missed() = create(status = AdherenceStatus.MISSED)
    fun skipped() = create(status = AdherenceStatus.SKIPPED)
}

// UserRelation Fixtures
object UserRelationFixture {
    fun create(
        id: UUID = UUID.randomUUID(),
        patientId: UUID = UUID.randomUUID(),
        caregiverId: UUID = UUID.randomUUID(),
        type: RelationType = RelationType.CAREGIVER_SOLICITED,
        status: RelationStatus = RelationStatus.ACTIVE,
        permissions: CaregiverPermissions = CaregiverPermissions.DEFAULT
    ) = UserRelation(id, patientId, caregiverId, type, status, permissions)
}

10.2. Data Sanitization

Reglas estrictas:

  1. NUNCA usar datos reales de pacientes
  2. NUNCA copiar PHI de produccion
  3. Usar generadores de datos falsos (Faker)
  4. Revisar fixtures antes de commit
// NO HACER:
let medication = Medication(name: "Juan Garcia - Metformina")

// SI HACER:
let medication = Medication.fixture(name: "Test User - Test Med")

11. CI/CD Integration

11.1. GitHub Actions Pipelines

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main, 'TechSpec-*']
  pull_request:
    branches: [main]

jobs:
  unit-tests-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run iOS Unit Tests
        run: |
          xcodebuild test \
            -scheme MedTime \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -enableCodeCoverage YES
      - name: Upload Coverage
        uses: codecov/codecov-action@v3

  unit-tests-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup JDK
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      - name: Run Android Unit Tests
        run: ./gradlew testDebugUnitTest
      - name: Upload Coverage
        uses: codecov/codecov-action@v3

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - name: Run RLS Tests
        run: |
          psql -h localhost -U postgres -f tests/db/setup.sql
          pg_prove tests/db/*.sql

  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Snyk Security Scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
      - name: OWASP Dependency Check
        uses: dependency-check/Dependency-Check_Action@main

11.2. Quality Gates

Gate Criterio Bloquea PR
Unit Test Coverage >= 80% Si
Integration Tests 100% pass Si
Security Scan 0 High/Critical Si
Performance Regression < 10% Warning
E2E Critical Paths 100% pass Si

11.2.1. Quality Gates Configurados (DV2-P2)

Agregado: TEST-MEDIO-004 - Quality gates completos para CI/CD.

# .github/workflows/quality-gates.yml
name: Quality Gates

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

env:
  COVERAGE_THRESHOLD: 80
  SECURITY_MAX_HIGH: 0
  SECURITY_MAX_CRITICAL: 0
  PERF_REGRESSION_THRESHOLD: 10

jobs:
  # Gate 1: Unit Test Coverage
  unit-coverage:
    name: "Gate: Unit Test Coverage >= 80%"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run iOS Unit Tests with Coverage
        run: |
          xcodebuild test \
            -scheme MedTime \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -enableCodeCoverage YES \
            -resultBundlePath TestResults.xcresult

      - name: Run Android Unit Tests with Coverage
        run: ./gradlew testDebugUnitTestCoverage

      - name: Check Coverage Threshold
        run: |
          IOS_COVERAGE=$(xcrun xccov view --report TestResults.xcresult --json | jq '.lineCoverage * 100')
          ANDROID_COVERAGE=$(cat android/build/reports/coverage/debug/report.xml | grep -oP 'line-rate="\K[^"]+' | head -1 | awk '{print $1 * 100}')

          echo "iOS Coverage: $IOS_COVERAGE%"
          echo "Android Coverage: $ANDROID_COVERAGE%"

          if (( $(echo "$IOS_COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then
            echo "::error::iOS coverage ($IOS_COVERAGE%) below threshold ($COVERAGE_THRESHOLD%)"
            exit 1
          fi

          if (( $(echo "$ANDROID_COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then
            echo "::error::Android coverage ($ANDROID_COVERAGE%) below threshold ($COVERAGE_THRESHOLD%)"
            exit 1
          fi

      - name: Upload Coverage to Codecov
        uses: codecov/codecov-action@v3

  # Gate 2: Integration Tests
  integration-tests:
    name: "Gate: Integration Tests 100% Pass"
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
    steps:
      - uses: actions/checkout@v4

      - name: Run API Contract Tests
        run: npm run test:contracts

      - name: Run Database Tests (pgTAP)
        run: |
          psql -h localhost -U postgres -f tests/db/setup.sql
          pg_prove --verbose tests/db/*.sql

      - name: Run RLS Policy Tests
        run: npm run test:rls

      - name: Verify All Passed
        run: |
          if [ -f test-results/failures.json ]; then
            FAILURES=$(cat test-results/failures.json | jq '.count')
            if [ "$FAILURES" -gt 0 ]; then
              echo "::error::$FAILURES integration tests failed"
              exit 1
            fi
          fi

  # Gate 3: Security Scan
  security-scan:
    name: "Gate: Security 0 High/Critical"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Snyk Security Scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

      - name: OWASP Dependency Check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: 'MedTime'
          format: 'JSON'
          failOnCVSS: 7

      - name: Check Security Results
        run: |
          CRITICAL=$(cat dependency-check-report.json | jq '[.dependencies[].vulnerabilities[]? | select(.severity == "CRITICAL")] | length')
          HIGH=$(cat dependency-check-report.json | jq '[.dependencies[].vulnerabilities[]? | select(.severity == "HIGH")] | length')

          echo "Critical vulnerabilities: $CRITICAL"
          echo "High vulnerabilities: $HIGH"

          if [ "$CRITICAL" -gt "$SECURITY_MAX_CRITICAL" ]; then
            echo "::error::$CRITICAL critical vulnerabilities found (max: $SECURITY_MAX_CRITICAL)"
            exit 1
          fi

          if [ "$HIGH" -gt "$SECURITY_MAX_HIGH" ]; then
            echo "::error::$HIGH high vulnerabilities found (max: $SECURITY_MAX_HIGH)"
            exit 1
          fi

  # Gate 4: Performance Regression
  performance-check:
    name: "Gate: Performance Regression < 10%"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Performance Benchmarks
        run: npm run test:performance

      - name: Compare with Baselines
        run: |
          python scripts/compare_baselines.py \
            --current test-results/performance.json \
            --baselines baselines/main.json \
            --threshold $PERF_REGRESSION_THRESHOLD \
            --output regression-report.json

      - name: Check Regression
        run: |
          REGRESSION=$(cat regression-report.json | jq '.max_regression_percent')
          echo "Max regression: $REGRESSION%"

          if (( $(echo "$REGRESSION > $PERF_REGRESSION_THRESHOLD" | bc -l) )); then
            echo "::warning::Performance regression detected: $REGRESSION% (threshold: $PERF_REGRESSION_THRESHOLD%)"
            # Warning only, no exit 1
          fi

  # Gate 5: E2E Critical Paths
  e2e-critical:
    name: "Gate: E2E Critical Paths 100% Pass"
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run iOS E2E Tests (Critical)
        run: |
          xcodebuild test \
            -scheme MedTime-E2E \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -only-testing:MedTimeE2ETests/CriticalPathTests

      - name: Run Android E2E Tests (Critical)
        run: ./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.medtime.e2e.CriticalPathTests

      - name: Verify Critical Paths
        run: |
          # Parse test results
          FAILED=$(cat test-results/e2e-results.json | jq '[.testcases[] | select(.status == "failed")] | length')

          if [ "$FAILED" -gt 0 ]; then
            echo "::error::$FAILED critical E2E tests failed"
            exit 1
          fi

  # Summary Gate
  all-gates:
    name: "All Quality Gates"
    needs: [unit-coverage, integration-tests, security-scan, performance-check, e2e-critical]
    runs-on: ubuntu-latest
    steps:
      - name: All Gates Passed
        run: |
          echo "## Quality Gates Summary" >> $GITHUB_STEP_SUMMARY
          echo "| Gate | Status |" >> $GITHUB_STEP_SUMMARY
          echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
          echo "| Unit Coverage >= 80% | ✅ |" >> $GITHUB_STEP_SUMMARY
          echo "| Integration Tests 100% | ✅ |" >> $GITHUB_STEP_SUMMARY
          echo "| Security 0 High/Critical | ✅ |" >> $GITHUB_STEP_SUMMARY
          echo "| Performance < 10% Regression | ✅ |" >> $GITHUB_STEP_SUMMARY
          echo "| E2E Critical Paths 100% | ✅ |" >> $GITHUB_STEP_SUMMARY
          echo ""
          echo "All quality gates passed! ✅"

11.2.2. Branch Protection Rules

# Repository Settings > Branches > main
protection_rules:
  main:
    required_status_checks:
      strict: true
      contexts:
        - "Gate: Unit Test Coverage >= 80%"
        - "Gate: Integration Tests 100% Pass"
        - "Gate: Security 0 High/Critical"
        - "Gate: E2E Critical Paths 100% Pass"
        - "All Quality Gates"
    required_pull_request_reviews:
      required_approving_review_count: 1
      dismiss_stale_reviews: true
    enforce_admins: true
    restrictions: null

  develop:
    required_status_checks:
      strict: true
      contexts:
        - "Gate: Unit Test Coverage >= 80%"
        - "Gate: Integration Tests 100% Pass"
        - "Gate: Security 0 High/Critical"
    required_pull_request_reviews:
      required_approving_review_count: 1

11.2.3. Gate Escalation Matrix

Gate Failure Severity Action Escalation
Unit Coverage < 80% High PR Blocked Dev Lead
Unit Coverage < 70% Critical PR Blocked + Alert Tech Lead
Integration Test Fail High PR Blocked Dev Lead
Security Critical Critical PR Blocked + Alert Security Team
Security High High PR Blocked Dev Lead
Performance > 10% Medium Warning Dev Team
Performance > 25% High PR Blocked Tech Lead
E2E Critical Fail Critical PR Blocked + Alert QA Lead

12. Matriz de Trazabilidad

Actualizado: DV2 - Se agregaron 14 modulos faltantes para cobertura completa.

12.1. Modulos Core (P0 - Bloqueantes)

Modulo Funcional Descripcion Test Suite Cobertura Target
MTS-AUTH-001 Autenticacion tests/auth/* 95%
MTS-MED-001 Medicamentos tests/medications/* 95%
MTS-USR-001 Usuarios/Perfiles tests/users/* 90%
MTS-ALT-001 Alertas/Recordatorios tests/alerts/* 95%
MTS-NTF-001 Notificaciones tests/notifications/* 90%
MTS-SEC-001 Seguridad/Crypto tests/security/* 98%
MTS-OFF-001 Modo Offline tests/offline/* 95%

12.2. Modulos Sync y Datos (P0-P1)

Modulo Funcional Descripcion Test Suite Cobertura Target
MTS-BCK-001 Backup/Restore tests/backup/* 95%
MTS-CAT-001 Catalogo Medicamentos tests/catalog/* 85%
MTS-RX-001 Recetas/Prescripciones tests/prescriptions/* 90%
MTS-ADH-001 Adherencia tests/adherence/* 90%

12.3. Modulos Secundarios (P1)

Modulo Funcional Descripcion Test Suite Cobertura Target
MTS-CAL-001 Calendario tests/calendar/* 85%
MTS-EVT-001 Eventos de Salud tests/events/* 85%
MTS-GAM-001 Gamificacion tests/gamification/* 80%
MTS-FAM-001 Familia/Cuidadores tests/family/* 90%
MTS-CIT-001 Citas Medicas tests/appointments/* 85%
MTS-RPT-001 Reportes tests/reports/* 80%
MTS-ANA-001 Analytics tests/analytics/* 75%

12.4. Modulos UI/UX (P2)

Modulo Funcional Descripcion Test Suite Cobertura Target
MTS-ONB-001 Onboarding tests/onboarding/* 85%
MTS-WDG-001 Widgets tests/widgets/* 80%
MTS-WCH-001 Watch/Wearables tests/wearables/* 75%
MTS-INT-001 Integraciones Salud tests/integrations/* 80%

12.5. Modulos Regulatorios (P1)

Modulo Funcional Descripcion Test Suite Cobertura Target
MTS-PRI-001 Privacidad/GDPR tests/privacy/* 98%
MTS-FHIR-001 Interoperabilidad FHIR tests/fhir/* 90%
MTS-REG-001 Cumplimiento Regulatorio tests/compliance/* 95%

12.6. Mapeo Detallado por Funcionalidad

Functional Spec Test Case Tipo Prioridad
MTS-AUTH-001-F01 (Registro) test_registration_* E2E P0
MTS-AUTH-001-F02 (Login) test_login_* Unit + E2E P0
MTS-AUTH-001-F03 (Biometrics) test_biometric_* Integration P0
MTS-MED-001-F01 (Agregar med) test_addMedication_* Unit + E2E P0
MTS-MED-001-F02 (Editar med) test_editMedication_* Unit + E2E P0
MTS-MED-001-F03 (Eliminar med) test_deleteMedication_* Unit + E2E P1
MTS-ALT-001-F01 (Recordatorios) test_reminder_* Unit + E2E P0
MTS-ALT-001-F02 (Snooze) test_snooze_* Unit P1
MTS-ALT-001-F03 (Emergencia) test_emergency_* E2E P0
MTS-SEC-001-F01 (Encryption) test_encrypt_* Unit P0
MTS-SEC-001-F02 (Key Derivation) test_argon2_* Unit P0
MTS-SEC-001-F03 (Zero-Knowledge) test_zeroknowledge_* Integration P0
MTS-OFF-001-F01 (Sync Queue) test_syncQueue_* Unit + Integration P0
MTS-OFF-001-F02 (Conflict Res) test_conflict_* Integration P0
MTS-BCK-001-F01 (Auto Backup) test_autoBackup_* Integration P0
MTS-BCK-001-F02 (Restore) test_restore_* E2E P0
MTS-ADH-001-F01 (Tracking) test_adherence_* Unit P1
MTS-ADH-001-F02 (Streaks) test_streak_* Unit P1
MTS-FAM-001-F01 (Invite) test_familyInvite_* E2E P1
MTS-FAM-001-F02 (Supervision) test_supervision_* Integration P1
MTS-PRI-001-F01 (Data Export) test_gdprExport_* E2E P0
MTS-PRI-001-F02 (Data Delete) test_gdprDelete_* E2E P0

12.7. Resumen de Cobertura

Categoria Modulos Cobertura Promedio Target
Core (P0) 7 94%
Sync/Datos (P0-P1) 4 90%
Secundarios (P1) 7 84%
UI/UX (P2) 4 80%
Regulatorios (P1) 3 94%
TOTAL 25 88%

13. Metricas y Reporting

13.1. Dashboard de Metricas

Metrica Target Actual Trend
Unit Test Coverage 80% - -
Integration Coverage 90% - -
E2E Pass Rate 95% - -
Security Issues 0 Critical - -
P99 Latency < 2s - -
Test Execution Time < 15min - -

13.2. Reporting

  • Codecov: Cobertura de codigo
  • SonarQube: Quality metrics
  • Grafana: Performance dashboards
  • Slack: Alertas de fallas

14. Referencias

Documento Proposito
01-arquitectura-tecnica.md Arquitectura a testear
02-arquitectura-cliente-servidor.md Arquitectura dual
04-seguridad-cliente.md Security tests E2E
05-seguridad-servidor.md Security tests RLS
APIs Contract testing
Database Models DB testing
OWASP Testing Guide Security testing

Documento generado por TestingDrone / SpecQueen Technical Division - IT-06