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:
2026-08-28 23:12:25 +02:00
parent d4df3822b7
commit b12a04ba30
10 changed files with 1096 additions and 26 deletions

View File

@ -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;