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
This commit is contained in:
@ -1216,10 +1216,19 @@ void main() {
|
||||
totalCaloriesKcal: 56,
|
||||
),
|
||||
);
|
||||
final activeWorkoutSensorUseCases =
|
||||
ActiveWorkoutSensorUseCases(clock: clock)..recordTelemetrySample(
|
||||
WatchSensorSample(
|
||||
sessionId: session.metadata.id,
|
||||
capturedAtEpochMs: clock.now().millisecondsSinceEpoch,
|
||||
stepCount: 1320,
|
||||
),
|
||||
);
|
||||
final useCase = CloseWorkoutSessionUseCase(
|
||||
sessionRepository: sessionRepository,
|
||||
historyRepository: historyRepository,
|
||||
telemetryRepository: telemetryRepository,
|
||||
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
@ -1236,7 +1245,13 @@ void main() {
|
||||
expect(history.maxHeartRateBpm, 151);
|
||||
expect(history.totalDistanceMeters, 720);
|
||||
expect(history.totalCaloriesKcal, 56);
|
||||
expect(history.totalSteps, 1320);
|
||||
expect(
|
||||
jsonDecode(history.historySnapshotJson),
|
||||
containsPair('totalSteps', 1320),
|
||||
);
|
||||
expect(historyRepository.histories.single, history);
|
||||
await activeWorkoutSensorUseCases.dispose();
|
||||
});
|
||||
|
||||
test('adjustRestSeconds persists adjusted rest duration', () async {
|
||||
@ -3240,6 +3255,7 @@ void main() {
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
caloriesKcal: 42,
|
||||
stepCount: 900,
|
||||
),
|
||||
);
|
||||
final duplicate = useCase.recordTelemetrySample(
|
||||
@ -3271,18 +3287,35 @@ void main() {
|
||||
).millisecondsSinceEpoch,
|
||||
distanceMeters: 620,
|
||||
caloriesKcal: 48,
|
||||
stepCount: 1320,
|
||||
),
|
||||
);
|
||||
final stepsOnly = useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-3',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
25,
|
||||
12,
|
||||
3,
|
||||
).millisecondsSinceEpoch,
|
||||
stepCount: 1480,
|
||||
),
|
||||
);
|
||||
|
||||
expect(first?.latestHeartRateBpm, 120);
|
||||
expect(duplicate, isNull);
|
||||
expect(distanceOnly?.sampleCount, 2);
|
||||
expect(stepsOnly?.sampleCount, 3);
|
||||
expect(distanceOnly?.latestHeartRateBpm, 120);
|
||||
expect(distanceOnly?.minHeartRateBpm, 120);
|
||||
expect(distanceOnly?.averageHeartRateBpm, 120);
|
||||
expect(distanceOnly?.maxHeartRateBpm, 120);
|
||||
expect(distanceOnly?.latestDistanceMeters, 620);
|
||||
expect(distanceOnly?.latestCaloriesKcal, 48);
|
||||
expect(stepsOnly?.latestStepCount, 1480);
|
||||
await useCase.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ void main() {
|
||||
late local.DriftWorkoutTelemetryRepository telemetryRepository;
|
||||
late local.DriftExercisePerformanceReferenceRepository
|
||||
performanceReferenceRepository;
|
||||
late local.DriftEntitlementSnapshotRepository entitlementSnapshotRepository;
|
||||
|
||||
setUp(() {
|
||||
database = local.AppDatabase(NativeDatabase.memory());
|
||||
@ -39,6 +40,9 @@ void main() {
|
||||
telemetryRepository = local.DriftWorkoutTelemetryRepository(database);
|
||||
performanceReferenceRepository =
|
||||
local.DriftExercisePerformanceReferenceRepository(database);
|
||||
entitlementSnapshotRepository = local.DriftEntitlementSnapshotRepository(
|
||||
database,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@ -148,7 +152,33 @@ void main() {
|
||||
await columnNames('workout_telemetry_aggregates'),
|
||||
contains('sample_count'),
|
||||
);
|
||||
expect(database.schemaVersion, 26);
|
||||
expect(await columnNames('entitlement_snapshots'), contains('entitlement'));
|
||||
expect(database.schemaVersion, 27);
|
||||
});
|
||||
|
||||
test('entitlement snapshot is persisted locally for offline reads', () async {
|
||||
final free = EntitlementSnapshot.free(
|
||||
remainingEditableSlots: 2,
|
||||
validatedAt: DateTime.utc(2026, 8, 22, 8),
|
||||
);
|
||||
await entitlementSnapshotRepository.save(free);
|
||||
|
||||
final restoredFree = await entitlementSnapshotRepository.read();
|
||||
expect(restoredFree?.entitlement, EntitlementTier.free);
|
||||
expect(restoredFree?.canSync, isFalse);
|
||||
expect(restoredFree?.remainingEditableSlots, 2);
|
||||
expect(restoredFree?.validatedAt, DateTime.utc(2026, 8, 22, 8));
|
||||
|
||||
final pro = EntitlementSnapshot.pro(
|
||||
validatedAt: DateTime.utc(2026, 8, 22, 9),
|
||||
);
|
||||
await entitlementSnapshotRepository.save(pro);
|
||||
|
||||
final restoredPro = await entitlementSnapshotRepository.read();
|
||||
expect(restoredPro?.entitlement, EntitlementTier.pro);
|
||||
expect(restoredPro?.canSync, isTrue);
|
||||
expect(restoredPro?.remainingEditableSlots, isNull);
|
||||
expect(restoredPro?.validatedAt, DateTime.utc(2026, 8, 22, 9));
|
||||
});
|
||||
|
||||
test('exercise business types persist with category fallback', () async {
|
||||
@ -2759,6 +2789,161 @@ CREATE TABLE pending_share_actions (
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'QA core offline journey persists exercise to progression through local repositories',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 8, 21, 9);
|
||||
final clock = _FakeClock(now);
|
||||
final ids = _FakeIds();
|
||||
final exerciseUseCases = ExerciseUseCases(
|
||||
repository: exerciseRepository,
|
||||
programRepository: programRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'qa-core-offline',
|
||||
);
|
||||
final programUseCases = ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: exerciseRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'qa-core-offline',
|
||||
);
|
||||
final templateUseCases = WorkoutTemplateUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'qa-core-offline',
|
||||
);
|
||||
final sessionUseCases = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'qa-core-offline',
|
||||
);
|
||||
final closeUseCase = CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeRepository,
|
||||
historyRepository: historyRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'qa-core-offline',
|
||||
);
|
||||
|
||||
final exercise = await exerciseUseCases.create(
|
||||
name: 'QA drift offline squat',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 8,
|
||||
tags: const ['qa', 'offline'],
|
||||
);
|
||||
final program = await programUseCases.saveConfigured(
|
||||
name: 'QA drift offline program',
|
||||
defaultRestSeconds: 30,
|
||||
tags: const ['qa', 'offline'],
|
||||
exercises: [
|
||||
ProgramExerciseConfig(
|
||||
sourceExerciseId: exercise.metadata.id,
|
||||
exerciseNameSnapshot: exercise.name,
|
||||
availableTimeSnapshot: exercise.hasTimeMeasure,
|
||||
availableRepsSnapshot: exercise.hasRepsMeasure,
|
||||
availableScoreSnapshot: exercise.hasScoreMeasure,
|
||||
setsCount: 1,
|
||||
enabledMeasures: const {WorkoutMeasure.reps},
|
||||
targetReps: 8,
|
||||
),
|
||||
],
|
||||
);
|
||||
final template = await templateUseCases.saveConfigured(
|
||||
name: 'QA drift offline template',
|
||||
tags: const ['qa', 'offline'],
|
||||
programs: [
|
||||
WorkoutTemplateProgramConfig(
|
||||
clientKey: 'program-1',
|
||||
sourceProgramId: program.metadata.id,
|
||||
programNameSnapshot: program.name,
|
||||
defaultRestSecondsSnapshot: program.defaultRestSeconds,
|
||||
programSnapshotJson: _programSnapshotJson(program),
|
||||
),
|
||||
],
|
||||
overrides: const [],
|
||||
);
|
||||
|
||||
final session = await sessionUseCases.startFromTemplate(
|
||||
template.metadata.id,
|
||||
);
|
||||
await sessionUseCases.recordCurrentSetResult(
|
||||
sessionId: session.metadata.id,
|
||||
programSnapshotId: template.programs.single.metadata.id,
|
||||
exerciseSnapshotId: program.exercises.single.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
actualReps: 9,
|
||||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||||
);
|
||||
|
||||
clock.value = now.add(const Duration(minutes: 6));
|
||||
await sessionUseCases.complete(session.metadata.id);
|
||||
final history = await closeUseCase.close(
|
||||
sessionId: session.metadata.id,
|
||||
nameSnapshot: template.name,
|
||||
completed: true,
|
||||
);
|
||||
|
||||
final restoredExercise = await exerciseRepository.findById(
|
||||
exercise.metadata.id,
|
||||
);
|
||||
final restoredProgram = await programRepository.findById(
|
||||
program.metadata.id,
|
||||
);
|
||||
final restoredTemplate = await templateRepository.findById(
|
||||
template.metadata.id,
|
||||
);
|
||||
final restoredHistory = await historyRepository.findById(
|
||||
history.metadata.id,
|
||||
);
|
||||
final progressionStats = await progressionStatsRepository.readGlobalStats(
|
||||
ProgressionDateRange(
|
||||
startedAt: now.subtract(const Duration(days: 1)),
|
||||
endedAt: now.add(const Duration(days: 1)),
|
||||
),
|
||||
);
|
||||
final progressionSeries = await progressionStatsRepository
|
||||
.readExerciseSeries(
|
||||
range: ProgressionDateRange(
|
||||
startedAt: now.subtract(const Duration(days: 1)),
|
||||
endedAt: now.add(const Duration(days: 1)),
|
||||
),
|
||||
exerciseKey: exercise.metadata.id,
|
||||
measure: ProgressionMeasure.reps,
|
||||
);
|
||||
|
||||
expect(restoredExercise, isNotNull);
|
||||
expect(restoredProgram, isNotNull);
|
||||
expect(restoredTemplate, isNotNull);
|
||||
expect(restoredHistory, isNotNull);
|
||||
expect(restoredHistory!.completed, isTrue);
|
||||
expect(restoredHistory.results, hasLength(1));
|
||||
expect(restoredHistory.results.single.actualReps, 9);
|
||||
expect(
|
||||
restoredHistory.results.single.sourceExerciseIdSnapshot,
|
||||
exercise.metadata.id,
|
||||
);
|
||||
expect(progressionStats.completedSessionCount, 1);
|
||||
expect(progressionStats.hasAnyCompletedHistory, isTrue);
|
||||
expect(progressionSeries.hasAnyCompletedExerciseResult, isTrue);
|
||||
expect(progressionSeries.points.single.rawValue, 9);
|
||||
expect(
|
||||
progressionSeries.points.single.workoutHistoryId,
|
||||
history.metadata.id,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'closing a session stores autonomous history rows with set snapshots',
|
||||
() async {
|
||||
@ -3733,14 +3918,26 @@ final class _ExerciseRoundTripCase {
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
_FakeClock(this.value);
|
||||
|
||||
final DateTime value;
|
||||
DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
String _programSnapshotJson(Program program) {
|
||||
return WorkoutTemplateProgram.snapshotFromProgram(
|
||||
metadata: _metadata(
|
||||
'snapshot-${program.metadata.id}',
|
||||
program.metadata.updatedAt,
|
||||
),
|
||||
workoutTemplateId: 'template-preview',
|
||||
program: program,
|
||||
position: 0,
|
||||
).programSnapshotJson;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
|
||||
@ -43,6 +43,18 @@ void main() {
|
||||
|
||||
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));
|
||||
|
||||
@ -111,6 +123,12 @@ void main() {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user