Files
GameTime/test/presentation/share_inbox_screen_test.dart
Blomios b12a04ba30 feat(monetization-261): prepare entitlement infrastructure and quota enforcement for release
- 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
2026-08-28 23:12:25 +02:00

611 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(
'À lacceptation, votre bibliothèque éditable recevra 1 programme. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsOneWidget,
);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 1 séance. Le quota receveur sera vérifié au moment de lacceptation.',
),
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(
'À lacceptation, votre bibliothèque éditable recevra 2 séances. Le quota receveur sera vérifié au moment de lacceptation.',
),
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 []}),
),
],
);
}