a9e277eb13
All six tests had been silently skipped under @Tags(['drift']) for 6+ months, so they accumulated test-vs-implementation drift. Now running against the libsqlite3-bearing ci-flutter:3.44 image, each failed for a distinct reason. Diagnoses below. audio_cache_manager_test 'usageBytes sums sizeBytes across rows': usageBytes() is a directory walk (authoritative on-disk total, catches orphan partials the index misses). The test inserted drift rows but never wrote files, so the walk returned 0. The actual API for summing drift sizeBytes is bucketUsage(). Rename to 'bucketUsage sums drift sizeBytes across rows', use that API, assert liked+rolling == 350. Also give the two rows unique paths per row-shape sanity. sync_controller_test (3 200-path tests, all returning null result): Map literals in Dart 3 with mixed value types infer as Map<String, Object>, not Map<String, dynamic>. The sync controller casts `resp.data as Map<String, dynamic>` (and several nested casts), which is invariant on generics and throws TypeError. The silent try/catch in sync() swallowed the throw and returned null. Real JSON parsing produces Map<String, dynamic>, so this never surfaced in production. Fix: route the test stub body through jsonDecode(jsonEncode(body)) in _stubDio — mimics real Dio's parsed-response shape. Affects '200 with artist upsert', '200 with track delete', and 'like_track upsert + delete round-trip'. quarantine_provider_test 'flag keeps drift optimistic + queues mutation on server failure': When the API stub throws, the controller catches + queues to CachedMutations. The drift watch() stream in MyQuarantineController was still in loading state when addTearDown disposed the container, tripping Riverpod's "StreamProvider disposed during loading" assertion. The success-path tests resolved before tearDown so they didn't see it. Fix: await one microtask before the test ends so the stream emits. like_button_test 'tap toggles icon optimistically; rollback on error': After the LikeButton was migrated to LucideHeart in the Lucide sweep, the prior fix replaced the find.byIcon assertion with a heartFilled() helper reading LucideHeart.filled. But likesController .toggle() goes through an async chain (optimistic state flip + await api.like + state notification), which one frame of tester.pump() doesn't flush. Use pumpAndSettle after both tap and rollback toggle so the widget rebuilds with the new state before the assertion. After this push, the drift cohort should be all-green on the libsqlite3-bearing image. Fable #399 / local #62. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
4.5 KiB
Dart
143 lines
4.5 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:drift/native.dart' show NativeDatabase;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
|
|
import 'package:minstrel/cache/db.dart';
|
|
import 'package:minstrel/cache/sync_controller.dart';
|
|
import 'package:minstrel/library/library_providers.dart' show dioProvider;
|
|
|
|
/// Builds a Dio whose adapter resolves every request to the supplied
|
|
/// status code + body. Avoids touching the network in tests.
|
|
///
|
|
/// Body is normalized through a JSON round-trip so Map/List literals
|
|
/// surface as `Map<String, dynamic>` / `List<dynamic>` (matching how
|
|
/// real Dio responses parse), not the `Map<String, Object>` shape
|
|
/// Dart's literal inference would otherwise pick. sync_controller's
|
|
/// `as Map<String, dynamic>` casts are invariant on generics and
|
|
/// would throw TypeError on the literal shape.
|
|
Dio _stubDio({required int status, dynamic body}) {
|
|
final dio = Dio();
|
|
final normalizedBody =
|
|
(body is Map || body is List) ? jsonDecode(jsonEncode(body)) : body;
|
|
dio.interceptors.add(InterceptorsWrapper(onRequest: (req, h) {
|
|
h.resolve(Response<dynamic>(
|
|
requestOptions: req,
|
|
statusCode: status,
|
|
data: normalizedBody,
|
|
));
|
|
}));
|
|
return dio;
|
|
}
|
|
|
|
ProviderContainer _container({required AppDb db, required Dio dio}) {
|
|
return ProviderContainer(overrides: [
|
|
appDbProvider.overrideWithValue(db),
|
|
dioProvider.overrideWith((ref) async => dio),
|
|
]);
|
|
}
|
|
|
|
void main() {
|
|
test('204 advances lastSyncAt without changing cursor', () async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
|
|
final container = _container(db: db, dio: _stubDio(status: 204));
|
|
addTearDown(container.dispose);
|
|
|
|
final result = await container.read(syncControllerProvider.notifier).sync();
|
|
expect(result?.upserts, 0);
|
|
expect(result?.deletes, 0);
|
|
final meta = await db.select(db.syncMetadata).getSingleOrNull();
|
|
expect(meta?.lastSyncAt, isNotNull);
|
|
});
|
|
|
|
test('200 with artist upsert writes drift row + advances cursor', () async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
|
|
final container = _container(
|
|
db: db,
|
|
dio: _stubDio(status: 200, body: {
|
|
'cursor': 7,
|
|
'upserts': {
|
|
'artist': [
|
|
{'id': 'a1', 'name': 'A', 'sort_name': 'A'},
|
|
],
|
|
},
|
|
'deletes': {},
|
|
}),
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final result = await container.read(syncControllerProvider.notifier).sync();
|
|
expect(result?.upserts, 1);
|
|
expect(result?.cursor, 7);
|
|
final artist = await (db.select(db.cachedArtists)
|
|
..where((t) => t.id.equals('a1')))
|
|
.getSingleOrNull();
|
|
expect(artist?.name, 'A');
|
|
final meta = await db.select(db.syncMetadata).getSingleOrNull();
|
|
expect(meta?.cursor, 7);
|
|
});
|
|
|
|
test('200 with track delete removes the row', () async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
|
|
// Seed an existing cached track
|
|
await db.into(db.cachedTracks).insertOnConflictUpdate(
|
|
CachedTracksCompanion.insert(
|
|
id: 't1', albumId: 'al1', artistId: 'ar1', title: 'song'),
|
|
);
|
|
|
|
final container = _container(
|
|
db: db,
|
|
dio: _stubDio(status: 200, body: {
|
|
'cursor': 3,
|
|
'upserts': {},
|
|
'deletes': {
|
|
'track': ['t1'],
|
|
},
|
|
}),
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final result = await container.read(syncControllerProvider.notifier).sync();
|
|
expect(result?.deletes, 1);
|
|
final track = await (db.select(db.cachedTracks)
|
|
..where((t) => t.id.equals('t1')))
|
|
.getSingleOrNull();
|
|
expect(track, isNull);
|
|
});
|
|
|
|
test('like_track upsert + delete round-trip', () async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
|
|
final container = _container(
|
|
db: db,
|
|
dio: _stubDio(status: 200, body: {
|
|
'cursor': 1,
|
|
'upserts': {
|
|
'like_track': [
|
|
{'user_id': 'u1', 'track_id': 't1'},
|
|
],
|
|
},
|
|
'deletes': {},
|
|
}),
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(syncControllerProvider.notifier).sync();
|
|
final liked = await db.select(db.cachedLikes).get();
|
|
expect(liked.length, 1);
|
|
expect(liked.first.userId, 'u1');
|
|
expect(liked.first.entityType, 'track');
|
|
expect(liked.first.entityId, 't1');
|
|
});
|
|
}
|