{{ theme.skipToContentLabel || 'Skip to content' }}

Integration Testing Document

Parent Document: TEST_PLAN.mdVersion: 1.2 | Date: January 5, 2026


1. Overview

1.1 Purpose

This document defines the integration testing strategy for the Dustac Environmental Monitoring Dashboard. Integration tests validate interactions between components, services, APIs, and the database.

1.2 Scope

  • Service-to-database interactions (Supabase)
  • API endpoint integration
  • Authentication flow integration
  • Storage operations (file upload/download)
  • Cross-feature module interactions
  • External API integrations (BOM, Dustac Scraper)

1.3 Tools & Framework

ToolVersionPurpose
Vitest^2.xTest runner
Supabase LocalLatestLocal database instance
MSW^2.xExternal API mocking
DockerLatestSupabase containers

2. Test Environment Setup

2.1 Local Supabase

bash
# Start local Supabase
pnpm supabase:start

# Verify status
pnpm supabase:status

# Reset database with seed data
pnpm supabase:db:reset

2.2 Environment Variables

bash
# .env.test.local
VITE_SUPABASE_URL=http://localhost:54321
VITE_SUPABASE_ANON_KEY=<local-anon-key>
SUPABASE_SERVICE_ROLE_KEY=<local-service-role-key>

2.3 Test Database Seeding

sql
-- supabase/seed.sql
INSERT INTO auth.users (id, email) VALUES
  ('user-001', 'test.user@dustac-test.com'),
  ('user-002', 'test.admin@dustac-test.com');

INSERT INTO cfg_mine_sites (id, site_name, user_id) VALUES
  ('site-001', 'Test Site Alpha', 'user-001'),
  ('site-002', 'Test Site Beta', 'user-002');

INSERT INTO cfg_devices (id, device_name, site_id) VALUES
  ('device-001', 'Device Alpha 1', 'site-001'),
  ('device-002', 'Device Beta 1', 'site-002');

3. Test Commands

bash
# Run integration tests
pnpm test:integration

# Run with local Supabase (requires Docker)
pnpm supabase:start && pnpm test:integration

# Run specific integration test
pnpm test:integration -- src/features/reports/services/reportDataService.integration.test.ts

# Run with verbose output
pnpm test:integration -- --reporter=verbose

4. Integration Test Cases

4.1 Database Integration

TC-INT-DB-001: RLS Policy Enforcement

FieldValue
Test IDTC-INT-DB-001
PriorityP0
TypeSecurity Integration

Test Code:

typescript
describe('RLS Policy Enforcement', () => {
  let user1Client: SupabaseClient;
  let user2Client: SupabaseClient;

  beforeAll(async () => {
    // Create authenticated clients for different users
    user1Client = await createAuthenticatedClient('user-001');
    user2Client = await createAuthenticatedClient('user-002');
  });

  it('should allow user to read own site data', async () => {
    const { data, error } = await user1Client
      .from('cfg_mine_sites')
      .select('*')
      .eq('id', 'site-001');

    expect(error).toBeNull();
    expect(data).toHaveLength(1);
    expect(data[0].site_name).toBe('Test Site Alpha');
  });

  it('should prevent user from reading other user site data', async () => {
    const { data, error } = await user1Client
      .from('cfg_mine_sites')
      .select('*')
      .eq('id', 'site-002'); // belongs to user-002

    expect(error).toBeNull();
    expect(data).toHaveLength(0); // RLS filters out
  });

  it('should prevent user from modifying other user data', async () => {
    const { error } = await user1Client
      .from('cfg_mine_sites')
      .update({ site_name: 'Hacked' })
      .eq('id', 'site-002');

    // Should either return error or affect 0 rows
    expect(error || true).toBeTruthy();
  });

  it('should enforce RLS on upload_measurements', async () => {
    // User 1 creates upload
    const { data: upload } = await user1Client
      .from('upload_sessions')
      .insert({ site_id: 'site-001' })
      .select()
      .single();

    // User 2 should not see it
    const { data } = await user2Client
      .from('upload_sessions')
      .select('*')
      .eq('id', upload.id);

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

TC-INT-DB-002: Upload Session Creation

FieldValue
Test IDTC-INT-DB-002
PriorityP0
TypeDatabase Integration

Test Code:

typescript
describe('Upload Session Integration', () => {
  it('should create upload session with measurements', async () => {
    const client = await createAuthenticatedClient('user-001');

    // Create upload session
    const { data: session, error: sessionError } = await client
      .from('upload_sessions')
      .insert({
        site_id: 'site-001',
        status: 'pending'
      })
      .select()
      .single();

    expect(sessionError).toBeNull();
    expect(session.id).toBeDefined();

    // Insert measurements
    const measurements = generateMeasurements({ count: 100, device: 'device-001' });
    const { error: measurementError } = await client
      .from('upload_measurements')
      .insert(measurements.map(m => ({
        ...m,
        upload_id: session.id
      })));

    expect(measurementError).toBeNull();

    // Verify count
    const { count } = await client
      .from('upload_measurements')
      .select('*', { count: 'exact', head: true })
      .eq('upload_id', session.id);

    expect(count).toBe(100);
  });

  it('should cascade delete measurements when upload deleted', async () => {
    const client = await createAuthenticatedClient('user-001');

    // Create session with measurements
    const { data: session } = await client
      .from('upload_sessions')
      .insert({ site_id: 'site-001' })
      .select()
      .single();

    await client.from('upload_measurements').insert([
      { upload_id: session.id, time: new Date().toISOString(), loc: 'device-001' }
    ]);

    // Delete session
    await client.from('upload_sessions').delete().eq('id', session.id);

    // Verify measurements deleted
    const { data } = await client
      .from('upload_measurements')
      .select('*')
      .eq('upload_id', session.id);

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

TC-INT-DB-003: Report Generation with Data

FieldValue
Test IDTC-INT-DB-003
PriorityP0
TypeDatabase Integration

Test Code:

typescript
describe('Report Generation Integration', () => {
  let uploadId: string;

  beforeAll(async () => {
    // Setup: Create upload with measurements
    const client = await createServiceRoleClient();
    const { data: session } = await client
      .from('upload_sessions')
      .insert({ site_id: 'site-001', user_id: 'user-001' })
      .select()
      .single();

    uploadId = session.id;

    // Insert test measurements with gaps
    const measurements = [
      ...generateMeasurements({ device: 'DEVICE-001', count: 50, startTime: '2025-01-01T00:00:00Z' }),
      ...generateMeasurements({ device: 'DEVICE-001', count: 50, startTime: '2025-01-01T05:00:00Z' }), // 3hr gap
      ...generateMeasurements({ device: 'DEVICE-002', count: 100, startTime: '2025-01-01T00:00:00Z' })
    ];

    await client.from('upload_measurements').insert(
      measurements.map(m => ({ ...m, upload_id: uploadId }))
    );
  });

  it('should fetch all measurements for report', async () => {
    const measurements = await ReportDataService.getMeasurements(uploadId);

    expect(measurements.length).toBe(200);
  });

  it('should calculate correct statistics', async () => {
    const measurements = await ReportDataService.getMeasurements(uploadId);
    const stats = ReportDataService.calculateStatistics(measurements);

    expect(stats.totalMeasurements).toBe(200);
    expect(stats.devices).toContain('DEVICE-001');
    expect(stats.devices).toContain('DEVICE-002');
  });

  it('should detect data gaps in timeline', async () => {
    const measurements = await ReportDataService.getMeasurements(uploadId);
    const timeline = ReportDataService.prepareDeviceTimeline(measurements);

    const device1 = timeline.find(d => d.device === 'DEVICE-001');
    const device2 = timeline.find(d => d.device === 'DEVICE-002');

    expect(device1?.dataGaps).toBe(1); // Has 3hr gap
    expect(device1?.segments).toHaveLength(2);
    expect(device2?.dataGaps).toBe(0); // Continuous
    expect(device2?.segments).toHaveLength(1);
  });

  it('should generate complete report template data', async () => {
    const templateData = await ReportDataService.generateReportTemplateData(uploadId);

    expect(templateData.reportInfo).toBeDefined();
    expect(templateData.statistics).toBeDefined();
    expect(templateData.chartData.deviceTimeline).toHaveLength(2);
    expect(templateData.chartData.timeSeries.length).toBeGreaterThan(0);
  });
});

4.2 Storage Integration

TC-INT-STORAGE-001: CSV File Upload

FieldValue
Test IDTC-INT-STORAGE-001
PriorityP0
TypeStorage Integration

Test Code:

typescript
describe('Storage Integration', () => {
  it('should upload CSV file to storage', async () => {
    const client = await createAuthenticatedClient('user-001');
    const csvContent = 'time,loc,value\n2025-01-01,DEVICE,10';
    const file = new File([csvContent], 'test.csv', { type: 'text/csv' });

    const { data, error } = await client.storage
      .from('csv-uploads')
      .upload(`user-001/test-${Date.now()}.csv`, file);

    expect(error).toBeNull();
    expect(data?.path).toContain('user-001');
  });

  it('should generate signed download URL', async () => {
    const client = await createAuthenticatedClient('user-001');

    // Upload file first
    const { data: uploadData } = await client.storage
      .from('csv-uploads')
      .upload(`user-001/signed-test.csv`, 'test content');

    // Get signed URL
    const { data: urlData, error } = await client.storage
      .from('csv-uploads')
      .createSignedUrl(uploadData.path, 3600); // 1 hour expiry

    expect(error).toBeNull();
    expect(urlData?.signedUrl).toContain('token=');
  });

  it('should prevent access to other user files', async () => {
    const user1Client = await createAuthenticatedClient('user-001');
    const user2Client = await createAuthenticatedClient('user-002');

    // User 1 uploads file
    await user1Client.storage
      .from('csv-uploads')
      .upload('user-001/private.csv', 'secret');

    // User 2 tries to download
    const { error } = await user2Client.storage
      .from('csv-uploads')
      .download('user-001/private.csv');

    expect(error).not.toBeNull();
  });
});

4.3 Authentication Integration

TC-INT-AUTH-001: Login Flow

FieldValue
Test IDTC-INT-AUTH-001
PriorityP0
TypeAuth Integration

Test Code:

typescript
describe('Authentication Integration', () => {
  it('should login with valid credentials', async () => {
    const { data, error } = await supabase.auth.signInWithPassword({
      email: 'test.user@dustac-test.com',
      password: 'testpassword123'
    });

    expect(error).toBeNull();
    expect(data.session).toBeDefined();
    expect(data.session.access_token).toBeDefined();
  });

  it('should reject invalid credentials', async () => {
    const { error } = await supabase.auth.signInWithPassword({
      email: 'test.user@dustac-test.com',
      password: 'wrongpassword'
    });

    expect(error).not.toBeNull();
    expect(error.message).toContain('Invalid');
  });

  it('should refresh session with valid refresh token', async () => {
    // Login first
    const { data: loginData } = await supabase.auth.signInWithPassword({
      email: 'test.user@dustac-test.com',
      password: 'testpassword123'
    });

    // Refresh session
    const { data: refreshData, error } = await supabase.auth.refreshSession({
      refresh_token: loginData.session.refresh_token
    });

    expect(error).toBeNull();
    expect(refreshData.session.access_token).not.toBe(loginData.session.access_token);
  });

  it('should logout and invalidate session', async () => {
    await supabase.auth.signInWithPassword({
      email: 'test.user@dustac-test.com',
      password: 'testpassword123'
    });

    await supabase.auth.signOut();

    const { data } = await supabase.auth.getSession();
    expect(data.session).toBeNull();
  });
});

4.4 External API Integration

TC-INT-API-001: BOM Weather API

FieldValue
Test IDTC-INT-API-001
PriorityP1
TypeExternal API Integration

Test Code:

typescript
describe('BOM Weather API Integration', () => {
  // Use MSW to mock external API
  beforeAll(() => {
    server.use(
      http.get('http://www.bom.gov.au/fwo/*', () => {
        return HttpResponse.json({
          observations: {
            data: [
              { local_date_time: '01/01:00pm', air_temp: 35.5, rel_hum: 45 }
            ]
          }
        });
      })
    );
  });

  it('should fetch weather data for station', async () => {
    const data = await WeatherService.fetchStationData('IDW60901');

    expect(data).toBeDefined();
    expect(data.observations).toHaveLength(1);
    expect(data.observations[0].air_temp).toBe(35.5);
  });

  it('should handle API timeout gracefully', async () => {
    server.use(
      http.get('http://www.bom.gov.au/fwo/*', async () => {
        await delay(10000); // Simulate timeout
        return HttpResponse.json({});
      })
    );

    await expect(WeatherService.fetchStationData('IDW60901'))
      .rejects.toThrow('timeout');
  });

  it('should cache weather data', async () => {
    // First call
    await WeatherService.fetchStationData('IDW60901');

    // Second call should use cache
    const startTime = Date.now();
    await WeatherService.fetchStationData('IDW60901');
    const duration = Date.now() - startTime;

    expect(duration).toBeLessThan(50); // Cached response is fast
  });
});

4.5 Cross-Feature Integration

TC-INT-CROSS-001: Upload to Report Flow

FieldValue
Test IDTC-INT-CROSS-001
PriorityP0
TypeCross-Feature Integration

Test Code:

typescript
describe('Upload to Report Integration', () => {
  it('should generate report from uploaded CSV', async () => {
    const client = await createAuthenticatedClient('user-001');

    // Step 1: Upload CSV
    const csvContent = generateTestCSV({ devices: 2, recordsPerDevice: 50 });
    const parseResult = await CsvParser.parse(csvContent);
    expect(parseResult.success).toBe(true);

    // Step 2: Create upload session
    const { data: session } = await client
      .from('upload_sessions')
      .insert({ site_id: 'site-001', status: 'processing' })
      .select()
      .single();

    // Step 3: Insert measurements
    await client.from('upload_measurements').insert(
      parseResult.data.map(m => ({ ...m, upload_id: session.id }))
    );

    // Step 4: Generate report data
    const reportData = await ReportDataService.generateReportTemplateData(session.id);

    expect(reportData.statistics.totalMeasurements).toBe(100);
    expect(reportData.chartData.deviceTimeline).toHaveLength(2);

    // Step 5: Create report record
    const { data: report } = await client
      .from('reports')
      .insert({
        upload_id: session.id,
        site_name: 'Test Site Alpha',
        status: 'completed',
        metadata: {
          statistics: reportData.statistics
        }
      })
      .select()
      .single();

    expect(report.status).toBe('completed');
  });
});

5. Test Configuration

5.1 Integration Test Config

typescript
// vitest.integration.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.integration.test.ts'],
    environment: 'node',
    testTimeout: 30000,
    hookTimeout: 30000,
    setupFiles: ['./src/test/integration-setup.ts'],
    globalSetup: './src/test/global-setup.ts',
    pool: 'forks', // Isolate tests
    poolOptions: {
      forks: {
        singleFork: true // Run sequentially for DB tests
      }
    }
  }
});

5.2 Global Setup

typescript
// src/test/global-setup.ts
export async function setup() {
  // Ensure Supabase is running
  console.log('Starting Supabase...');
  execSync('pnpm supabase:start', { stdio: 'inherit' });

  // Reset database
  console.log('Resetting database...');
  execSync('pnpm supabase:db:reset', { stdio: 'inherit' });
}

export async function teardown() {
  // Optional: Stop Supabase after tests
  // execSync('pnpm supabase:stop');
}

6. Test Utilities

6.1 Authenticated Client Helper

typescript
// src/test/helpers/supabase.ts

export async function createAuthenticatedClient(userId: string): Promise<SupabaseClient> {
  const { data } = await supabase.auth.signInWithPassword({
    email: `${userId}@dustac-test.com`,
    password: 'testpassword123'
  });

  return createClient(
    process.env.VITE_SUPABASE_URL!,
    process.env.VITE_SUPABASE_ANON_KEY!,
    {
      global: {
        headers: {
          Authorization: `Bearer ${data.session.access_token}`
        }
      }
    }
  );
}

export async function createServiceRoleClient(): Promise<SupabaseClient> {
  return createClient(
    process.env.VITE_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
}

Related Documents: