- Add EntitlementSnapshot, EntitlementRevalidationUseCase, and BillingUseCases - Add DriftEntitlementSnapshotRepository for offline entitlement caching - Add HealthConnect gateway and integration - Add quota enforcement in share acceptance (server and UI) - Add entitlement API endpoints in server - Add privacy policy v1 and release compliance checklist - Add QA gates and device runbooks for release validation - Add demo content screen and settings screen Ticket #261
611 lines
17 KiB
Dart
611 lines
17 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:gametime/application/application.dart';
|
||
import 'package:gametime/domain/domain.dart';
|
||
import 'package:gametime/presentation/share_inbox_screen.dart';
|
||
import 'package:gametime/presentation/share_screen.dart';
|
||
|
||
void main() {
|
||
testWidgets('la boîte de réception affiche les partages et les actions', (
|
||
tester,
|
||
) async {
|
||
final remoteShareApi = _FakeRemoteShareApi()
|
||
..inboxItems = [
|
||
_shareItem(
|
||
shareId: 'share-program',
|
||
resourceType: ShareResourceType.program,
|
||
name: 'Programme tirs',
|
||
status: ShareInboxStatus.pending,
|
||
),
|
||
_shareItem(
|
||
shareId: 'share-template',
|
||
resourceType: ShareResourceType.workoutTemplate,
|
||
name: 'Séance match',
|
||
status: ShareInboxStatus.pending,
|
||
),
|
||
];
|
||
final inboxRepository = _FakeShareInboxRepository();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ShareInboxScreen(
|
||
shareUseCases: _shareUseCases(
|
||
remoteShareApi: remoteShareApi,
|
||
inboxRepository: inboxRepository,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.text('Programme tirs'), findsOneWidget);
|
||
expect(find.text('Séance match'), findsOneWidget);
|
||
expect(
|
||
find.text(
|
||
'À l’acceptation, votre bibliothèque éditable recevra 1 programme. Le quota receveur sera vérifié au moment de l’acceptation.',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
find.text(
|
||
'À l’acceptation, votre bibliothèque éditable recevra 1 séance. Le quota receveur sera vérifié au moment de l’acceptation.',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('Accepter'), findsNWidgets(2));
|
||
expect(find.text('Refuser'), findsNWidgets(2));
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Accepter').first);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(remoteShareApi.acceptCalls, 1);
|
||
expect(
|
||
inboxRepository.items
|
||
.firstWhere((item) => item.shareId == 'share-program')
|
||
.status,
|
||
ShareInboxStatus.accepted,
|
||
);
|
||
expect(find.text('Programme ajouté.'), findsOneWidget);
|
||
|
||
await tester.tap(find.widgetWithText(OutlinedButton, 'Refuser').first);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(remoteShareApi.declineCalls, 1);
|
||
expect(
|
||
inboxRepository.items
|
||
.firstWhere((item) => item.shareId == 'share-template')
|
||
.status,
|
||
ShareInboxStatus.declined,
|
||
);
|
||
});
|
||
|
||
testWidgets('un pack reçu affiche un détail et importe toutes les séances', (
|
||
tester,
|
||
) async {
|
||
final remoteShareApi = _FakeRemoteShareApi()
|
||
..inboxItems = [
|
||
_shareItem(
|
||
shareId: 'share-pack',
|
||
resourceType: ShareResourceType.pack,
|
||
name: 'Pack reprise',
|
||
status: ShareInboxStatus.pending,
|
||
),
|
||
];
|
||
final inboxRepository = _FakeShareInboxRepository();
|
||
final templateRepository = _FakeWorkoutTemplateRepository();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ShareInboxScreen(
|
||
shareUseCases: _shareUseCases(
|
||
remoteShareApi: remoteShareApi,
|
||
inboxRepository: inboxRepository,
|
||
templateRepository: templateRepository,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.text('Pack reprise'), findsOneWidget);
|
||
expect(find.text('Pack · 2 séances'), findsOneWidget);
|
||
expect(find.text('Voir le pack'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Voir le pack'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Pack reçu'), findsOneWidget);
|
||
expect(find.text('Séance tirs'), findsOneWidget);
|
||
expect(find.text('Séance jambes'), findsOneWidget);
|
||
expect(
|
||
find.text(
|
||
'À l’acceptation, votre bibliothèque éditable recevra 2 séances. Le quota receveur sera vérifié au moment de l’acceptation.',
|
||
),
|
||
findsWidgets,
|
||
);
|
||
|
||
await tester.tap(find.text('Importer'));
|
||
await tester.pump();
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(templateRepository.saved, hasLength(2));
|
||
expect(
|
||
templateRepository.saved.map((template) => template.name),
|
||
containsAll(['Séance tirs', 'Séance jambes']),
|
||
);
|
||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||
expect(find.text('Pack importé.'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('composer un pack ouvre le formulaire de partage pack', (
|
||
tester,
|
||
) async {
|
||
final remoteShareApi = _FakeRemoteShareApi();
|
||
final templateRepository = _FakeWorkoutTemplateRepository()
|
||
..templates.add(_workoutTemplate('template-1', 'Séance tirs'))
|
||
..templates.add(_workoutTemplate('template-2', 'Séance jambes'));
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: SharePackSelectionScreen(
|
||
shareUseCases: _shareUseCases(
|
||
remoteShareApi: remoteShareApi,
|
||
inboxRepository: _FakeShareInboxRepository(),
|
||
templateRepository: templateRepository,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.enterText(
|
||
find.widgetWithText(TextField, 'Nom du pack'),
|
||
'Pack reprise',
|
||
);
|
||
await tester.tap(find.text('Séance tirs'));
|
||
await tester.tap(find.text('Séance jambes'));
|
||
await tester.pump();
|
||
await tester.tap(find.text('Continuer'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Partager le pack'), findsOneWidget);
|
||
expect(find.text('Pack reprise'), findsOneWidget);
|
||
expect(find.text('2 séances'), findsOneWidget);
|
||
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, 'Emails destinataires'),
|
||
'coach@example.com',
|
||
);
|
||
await tester.tap(find.text('Envoyer le partage'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(remoteShareApi.lastResourceType, ShareResourceType.pack);
|
||
expect(remoteShareApi.lastPayload?['name'], 'Pack reprise');
|
||
expect(remoteShareApi.lastPayload?['workouts'], hasLength(2));
|
||
});
|
||
|
||
testWidgets(
|
||
'accepter un partage en cache hors connexion importe une copie locale',
|
||
(tester) async {
|
||
final remoteShareApi = _FakeRemoteShareApi();
|
||
final inboxRepository = _FakeShareInboxRepository()
|
||
..items.add(
|
||
_shareItem(
|
||
shareId: 'share-program',
|
||
resourceType: ShareResourceType.program,
|
||
name: 'Programme tirs',
|
||
status: ShareInboxStatus.pending,
|
||
),
|
||
);
|
||
final programRepository = _FakeProgramRepository();
|
||
final pendingRepository = _FakePendingShareActionRepository();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ShareInboxScreen(
|
||
shareUseCases: _shareUseCases(
|
||
tokenStore: _FakeAuthTokenStore(null),
|
||
remoteShareApi: remoteShareApi,
|
||
inboxRepository: inboxRepository,
|
||
pendingActionRepository: pendingRepository,
|
||
programRepository: programRepository,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Accepter'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(remoteShareApi.acceptCalls, 0);
|
||
expect(programRepository.saved.single.name, 'Programme tirs');
|
||
expect(
|
||
programRepository.saved.single.metadata.id,
|
||
isNot('share-program'),
|
||
);
|
||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||
expect(
|
||
pendingRepository.actions.single.actionType,
|
||
PendingShareActionType.accept,
|
||
);
|
||
expect(find.text('Programme ajouté.'), findsOneWidget);
|
||
},
|
||
);
|
||
}
|
||
|
||
ShareUseCases _shareUseCases({
|
||
_FakeAuthTokenStore? tokenStore,
|
||
required _FakeRemoteShareApi remoteShareApi,
|
||
required _FakeShareInboxRepository inboxRepository,
|
||
_FakePendingShareActionRepository? pendingActionRepository,
|
||
_FakeProgramRepository? programRepository,
|
||
_FakeWorkoutTemplateRepository? templateRepository,
|
||
}) {
|
||
return ShareUseCases(
|
||
tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
|
||
remoteShareApi: remoteShareApi,
|
||
inboxRepository: inboxRepository,
|
||
pendingActionRepository:
|
||
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
||
localChanges: _FakeLocalSyncChangeRepository(),
|
||
programRepository: programRepository ?? _FakeProgramRepository(),
|
||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
ShareInboxItem _shareItem({
|
||
required String shareId,
|
||
required ShareResourceType resourceType,
|
||
required String name,
|
||
required ShareInboxStatus status,
|
||
}) {
|
||
final payload = switch (resourceType) {
|
||
ShareResourceType.program => {
|
||
'name': name,
|
||
'exercises': const [
|
||
{
|
||
'exerciseNameSnapshot': 'Sprint',
|
||
'setsCount': 3,
|
||
'availableTimeSnapshot': false,
|
||
'availableRepsSnapshot': true,
|
||
'availableScoreSnapshot': false,
|
||
'timeEnabled': false,
|
||
'repsEnabled': true,
|
||
'scoreEnabled': false,
|
||
'targetReps': 10,
|
||
},
|
||
],
|
||
},
|
||
ShareResourceType.workoutTemplate => {
|
||
'name': name,
|
||
'programs': [
|
||
{
|
||
'programSnapshotJson': jsonEncode({
|
||
'exercises': const [
|
||
{'id': 'exercise-1'},
|
||
],
|
||
}),
|
||
},
|
||
],
|
||
},
|
||
ShareResourceType.pack => {
|
||
'name': name,
|
||
'workouts': [
|
||
_templatePayload('template-1', 'Séance tirs'),
|
||
_templatePayload('template-2', 'Séance jambes'),
|
||
],
|
||
},
|
||
};
|
||
return ShareInboxItem(
|
||
shareId: shareId,
|
||
senderUserId: 'Alex',
|
||
resourceType: resourceType,
|
||
payloadJson: jsonEncode(payload),
|
||
status: status,
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
);
|
||
}
|
||
|
||
Map<String, Object?> _templatePayload(String id, String name) {
|
||
return {
|
||
'id': id,
|
||
'name': name,
|
||
'programs': [
|
||
{
|
||
'id': '$id-program',
|
||
'position': 0,
|
||
'programNameSnapshot': 'Programme',
|
||
'defaultRestSecondsSnapshot': 60,
|
||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||
},
|
||
],
|
||
'overrides': const [],
|
||
};
|
||
}
|
||
|
||
final class _FakeClock implements Clock {
|
||
const _FakeClock(this.value);
|
||
|
||
final DateTime value;
|
||
|
||
@override
|
||
DateTime now() => value;
|
||
}
|
||
|
||
final class _FakeIds implements IdGenerator {
|
||
var _next = 0;
|
||
|
||
@override
|
||
String newId() {
|
||
_next += 1;
|
||
return 'id-$_next';
|
||
}
|
||
}
|
||
|
||
final class _FakeAuthTokenStore implements AuthTokenStore {
|
||
_FakeAuthTokenStore(this.token);
|
||
|
||
String? token;
|
||
|
||
@override
|
||
Future<void> clearToken() async {
|
||
token = null;
|
||
}
|
||
|
||
@override
|
||
Future<String?> readToken() async => token;
|
||
|
||
@override
|
||
Future<void> saveToken(String token, DateTime expiresAt) async {
|
||
this.token = token;
|
||
}
|
||
}
|
||
|
||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||
List<ShareInboxItem> inboxItems = const [];
|
||
var acceptCalls = 0;
|
||
var declineCalls = 0;
|
||
ShareResourceType? lastResourceType;
|
||
Map<String, Object?>? lastPayload;
|
||
|
||
@override
|
||
Future<List<RemoteSyncedItem>> acceptShare(
|
||
String shareId,
|
||
String token,
|
||
) async {
|
||
acceptCalls += 1;
|
||
_mark(shareId, ShareInboxStatus.accepted);
|
||
return [
|
||
RemoteSyncedItem(
|
||
resourceType: SyncResourceType.program,
|
||
clientId: 'shared-program-1',
|
||
serverId: 'server-program-1',
|
||
schemaVersion: 1,
|
||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||
deletedAt: null,
|
||
payload: const {'name': 'Programme tirs'},
|
||
),
|
||
];
|
||
}
|
||
|
||
@override
|
||
Future<void> declineShare(String shareId, String token) async {
|
||
declineCalls += 1;
|
||
_mark(shareId, ShareInboxStatus.declined);
|
||
}
|
||
|
||
@override
|
||
Future<List<ShareInboxItem>> fetchInbox(String token) async => inboxItems;
|
||
|
||
@override
|
||
Future<void> revokeShare(String shareId, String token) async {
|
||
_mark(shareId, ShareInboxStatus.revoked);
|
||
}
|
||
|
||
@override
|
||
Future<RemoteShareSendResult> sendShare({
|
||
required ShareResourceType resourceType,
|
||
required Map<String, Object?> payload,
|
||
required List<String> recipientEmails,
|
||
required String token,
|
||
}) async {
|
||
lastResourceType = resourceType;
|
||
lastPayload = payload;
|
||
return const RemoteShareSendResult(
|
||
shareId: 'share-1',
|
||
recipientUserIds: ['recipient-1'],
|
||
unresolvedEmails: [],
|
||
);
|
||
}
|
||
|
||
void _mark(String shareId, ShareInboxStatus status) {
|
||
inboxItems = [
|
||
for (final item in inboxItems)
|
||
if (item.shareId == shareId)
|
||
item.copyWith(status: status, respondedAt: DateTime.utc(2026, 7, 17))
|
||
else
|
||
item,
|
||
];
|
||
}
|
||
}
|
||
|
||
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||
final items = <ShareInboxItem>[];
|
||
|
||
@override
|
||
Future<ShareInboxItem?> findByShareId(String shareId) async {
|
||
return items.cast<ShareInboxItem?>().firstWhere(
|
||
(item) => item?.shareId == shareId,
|
||
orElse: () => null,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<List<ShareInboxItem>> listAll() async => List.of(items);
|
||
|
||
@override
|
||
Future<void> markStatus(
|
||
String shareId,
|
||
ShareInboxStatus status,
|
||
DateTime respondedAt,
|
||
) async {
|
||
final index = items.indexWhere((item) => item.shareId == shareId);
|
||
if (index == -1) return;
|
||
items[index] = items[index].copyWith(
|
||
status: status,
|
||
respondedAt: respondedAt,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> upsert(ShareInboxItem item) async {
|
||
items.removeWhere((existing) => existing.shareId == item.shareId);
|
||
items.add(item);
|
||
}
|
||
|
||
@override
|
||
Future<void> upsertAll(List<ShareInboxItem> items) async {
|
||
for (final item in items) {
|
||
await upsert(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
final class _FakePendingShareActionRepository
|
||
implements PendingShareActionRepository {
|
||
final actions = <PendingShareAction>[];
|
||
|
||
@override
|
||
Future<void> add(PendingShareAction action) async {
|
||
actions.add(action);
|
||
}
|
||
|
||
@override
|
||
Future<List<PendingShareAction>> listPending() async => const [];
|
||
|
||
@override
|
||
Future<void> markFailed(String id, DateTime attemptedAt) async {}
|
||
|
||
@override
|
||
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
|
||
}
|
||
|
||
final class _FakeLocalSyncChangeRepository
|
||
implements LocalSyncChangeRepository {
|
||
@override
|
||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
|
||
|
||
@override
|
||
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
|
||
|
||
@override
|
||
Future<void> markChangesSynced(
|
||
List<String> changeLogIds,
|
||
DateTime syncedAt,
|
||
) async {}
|
||
}
|
||
|
||
final class _FakeProgramRepository implements ProgramRepository {
|
||
final saved = <Program>[];
|
||
|
||
@override
|
||
Future<Program?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<Program>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||
|
||
@override
|
||
Future<void> save(Program program) async {
|
||
saved.add(program);
|
||
}
|
||
|
||
@override
|
||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||
}
|
||
|
||
final class _FakeWorkoutTemplateRepository
|
||
implements WorkoutTemplateRepository {
|
||
final templates = <WorkoutTemplate>[];
|
||
final saved = <WorkoutTemplate>[];
|
||
|
||
@override
|
||
Future<WorkoutTemplate?> findById(String id) async {
|
||
return templates
|
||
.where((template) => template.metadata.id == id)
|
||
.firstOrNull;
|
||
}
|
||
|
||
@override
|
||
Future<List<WorkoutTemplate>> listActive() async => List.of(templates);
|
||
|
||
@override
|
||
Future<void> replaceComposition(
|
||
WorkoutTemplate template,
|
||
DateTime deletedAt,
|
||
) async {}
|
||
|
||
@override
|
||
Future<void> save(WorkoutTemplate template) async {
|
||
saved.add(template);
|
||
}
|
||
|
||
@override
|
||
Future<void> saveAll(List<WorkoutTemplate> templates) async {
|
||
for (final template in templates) {
|
||
await save(template);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||
|
||
@override
|
||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||
}
|
||
|
||
WorkoutTemplate _workoutTemplate(String id, String name) {
|
||
return WorkoutTemplate(
|
||
metadata: EntityMetadata(
|
||
id: id,
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
updatedAt: DateTime.utc(2026, 7, 17),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
name: name,
|
||
programs: [
|
||
WorkoutTemplateProgram(
|
||
metadata: EntityMetadata(
|
||
id: '$id-program',
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
updatedAt: DateTime.utc(2026, 7, 17),
|
||
originDeviceId: 'device-1',
|
||
),
|
||
workoutTemplateId: id,
|
||
sourceProgramId: null,
|
||
position: 0,
|
||
programNameSnapshot: 'Programme',
|
||
defaultRestSecondsSnapshot: 60,
|
||
programSnapshotJson: jsonEncode({'exercises': const []}),
|
||
),
|
||
],
|
||
);
|
||
}
|