3cb980b629
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
228 lines
9.3 KiB
Dart
228 lines
9.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:tasq/providers/connectivity_provider.dart';
|
|
import 'package:tasq/widgets/offline_banner.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Offline / Connectivity Tests
|
|
//
|
|
// Three test tiers:
|
|
// 1. Unit — pure provider state (no Flutter, no Supabase)
|
|
// 2. Widget — OfflineBanner visibility
|
|
// 3. Integration stubs — AppRepository + SQLite (require Supabase setup,
|
|
// skipped in CI; see comments for manual verification steps)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void main() {
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Tier 1 — Provider unit tests
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
group('isOnlineProvider', () {
|
|
test('starts as true (online by default)', () {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
expect(container.read(isOnlineProvider), isTrue);
|
|
});
|
|
|
|
test('can be set to false (simulate going offline)', () {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(isOnlineProvider.notifier).state = false;
|
|
expect(container.read(isOnlineProvider), isFalse);
|
|
});
|
|
|
|
test('can transition false → true (simulate reconnect)', () {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(isOnlineProvider.notifier).state = false;
|
|
expect(container.read(isOnlineProvider), isFalse);
|
|
|
|
container.read(isOnlineProvider.notifier).state = true;
|
|
expect(container.read(isOnlineProvider), isTrue);
|
|
});
|
|
|
|
test('listeners are notified on state change', () {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
final states = <bool>[];
|
|
container.listen(isOnlineProvider, (_, next) => states.add(next));
|
|
|
|
container.read(isOnlineProvider.notifier).state = false;
|
|
container.read(isOnlineProvider.notifier).state = true;
|
|
container.read(isOnlineProvider.notifier).state = false;
|
|
|
|
expect(states, equals([false, true, false]));
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Tier 2 — Widget tests for OfflineBanner
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
group('OfflineBanner widget', () {
|
|
/// Builds OfflineBanner inside a ProviderScope that overrides
|
|
/// [isOnlineProvider] to [online] and [connectivityMonitorProvider]
|
|
/// to a no-op so no real HTTP calls are made.
|
|
Widget buildBanner({required bool online}) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
isOnlineProvider.overrideWith((ref) => online),
|
|
// Suppress the real connectivity poll — no network in tests.
|
|
connectivityMonitorProvider.overrideWith((_) {}),
|
|
],
|
|
child: const MaterialApp(
|
|
home: Scaffold(
|
|
body: OfflineBanner(child: SizedBox.expand()),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
testWidgets('hides banner when online', (tester) async {
|
|
await tester.pumpWidget(buildBanner(online: true));
|
|
await tester.pump();
|
|
|
|
expect(find.byIcon(Icons.wifi_off_rounded), findsNothing);
|
|
expect(
|
|
find.text(
|
|
'No internet \u2014 changes saved locally',
|
|
),
|
|
findsNothing,
|
|
);
|
|
});
|
|
|
|
testWidgets('shows banner when offline', (tester) async {
|
|
await tester.pumpWidget(buildBanner(online: false));
|
|
await tester.pump();
|
|
|
|
expect(find.byIcon(Icons.wifi_off_rounded), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'No internet \u2014 changes saved locally',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('banner transitions from offline to online', (tester) async {
|
|
// Use UncontrolledProviderScope so we can update provider state in-place
|
|
// without replacing the widget tree (which would reset AnimatedSize).
|
|
final container = ProviderContainer(
|
|
overrides: [connectivityMonitorProvider.overrideWith((_) {})],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(isOnlineProvider.notifier).state = false;
|
|
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: const MaterialApp(
|
|
home: Scaffold(body: OfflineBanner(child: SizedBox.expand())),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(find.byIcon(Icons.wifi_off_rounded), findsOneWidget);
|
|
|
|
// Go online — update provider state, then let AnimatedSize animate.
|
|
container.read(isOnlineProvider.notifier).state = true;
|
|
await tester.pumpAndSettle();
|
|
expect(find.byIcon(Icons.wifi_off_rounded), findsNothing);
|
|
});
|
|
|
|
testWidgets('banner transitions from online to offline', (tester) async {
|
|
final container = ProviderContainer(
|
|
overrides: [connectivityMonitorProvider.overrideWith((_) {})],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
// Start online (default is true).
|
|
await tester.pumpWidget(
|
|
UncontrolledProviderScope(
|
|
container: container,
|
|
child: const MaterialApp(
|
|
home: Scaffold(body: OfflineBanner(child: SizedBox.expand())),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(find.byIcon(Icons.wifi_off_rounded), findsNothing);
|
|
|
|
// Go offline — update provider state, then let AnimatedSize animate.
|
|
container.read(isOnlineProvider.notifier).state = false;
|
|
await tester.pumpAndSettle();
|
|
expect(find.byIcon(Icons.wifi_off_rounded), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('child widget is always rendered regardless of connectivity',
|
|
(tester) async {
|
|
const childKey = Key('test-child');
|
|
|
|
for (final online in [true, false]) {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
isOnlineProvider.overrideWith((ref) => online),
|
|
connectivityMonitorProvider.overrideWith((_) {}),
|
|
],
|
|
child: const MaterialApp(
|
|
home: Scaffold(
|
|
body: OfflineBanner(
|
|
child: SizedBox.expand(key: childKey),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(find.byKey(childKey), findsOneWidget,
|
|
reason: 'child must be visible when online=$online');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// Tier 3 — Integration stubs (AppRepository + SQLite + Supabase)
|
|
//
|
|
// These tests are NOT run automatically because they require:
|
|
// • a real (or local) Supabase instance
|
|
// • the full AppRepository.configure() call with a valid client
|
|
//
|
|
// To run manually:
|
|
// 1. Start a local Supabase dev stack: `supabase start`
|
|
// 2. Set SUPABASE_URL / SUPABASE_ANON_KEY env vars
|
|
// 3. Run: flutter test test/offline_sync_test.dart --name "integration"
|
|
//
|
|
// Scenario A — offline create → sync
|
|
// 1. Set AppRepository offline (no network / airplane mode mock)
|
|
// 2. Call AppRepository.instance.upsert<Task>(task)
|
|
// 3. Read back with OfflineFirstGetPolicy.localOnly → expect task present
|
|
// 4. Restore network
|
|
// 5. Wait for Brick HTTP queue to replay
|
|
// 6. Read with OfflineFirstGetPolicy.requireRemote → expect task synced
|
|
// 7. Verify task_number is now server-assigned (not null)
|
|
//
|
|
// Scenario B — online read → go offline → read from cache
|
|
// 1. Hydrate: AppRepository.instance.get<Task>() (pulls from Supabase)
|
|
// 2. Simulate offline (cut network)
|
|
// 3. AppRepository.instance.get<Task>(policy: localOnly) → expect cached
|
|
// 4. No exception thrown
|
|
//
|
|
// Scenario C — task number placeholder
|
|
// 1. Create task offline → task.taskNumber == null
|
|
// 2. UI should display 'OFFLINE-001' (handled by displayTaskNumber helper)
|
|
// 3. After sync → task.taskNumber != null → UI shows 'YYYY-MM-#####'
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
group('integration (manual only)', () {
|
|
// Placeholder so the group shows up in test output with a clear note.
|
|
test('see file comments for manual integration test instructions', () {
|
|
// This test intentionally passes — it documents the manual steps above.
|
|
});
|
|
});
|
|
}
|