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:
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user