test(favorites): cover sqlite migrations
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s

This commit is contained in:
2026-05-22 17:20:40 +02:00
parent f6a9ba0086
commit b6e66e75ce
5 changed files with 172 additions and 8 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
## Favoritos ## Favoritos
- [ ] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos. - [x] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos.
- [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia. - [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia.
## Agrupaciones de favoritos ## Agrupaciones de favoritos
+36 -7
View File
@@ -8,12 +8,24 @@ import '../modelos/grupo_favoritos.dart';
/// Servicio de persistencia de emisoras favoritas con SQLite. /// Servicio de persistencia de emisoras favoritas con SQLite.
/// ///
/// - Inicializaci?n lazy: la BD se abre en el primer acceso. /// - Inicialización lazy: la BD se abre en el primer acceso.
/// - Migration-ready: versi?n 3 a?ade agrupaciones de favoritos. /// - Migration-ready: versión 3 añade agrupaciones de favoritos.
class ServicioFavoritos { class ServicioFavoritos {
static const _dbName = 'pluriwave.db'; static const _dbName = 'pluriwave.db';
static const _dbVersion = 3; static const _dbVersion = 3;
ServicioFavoritos({
DatabaseFactory? databaseFactory,
Future<String> Function()? databasePathProvider,
String? databaseName,
}) : _databaseFactory = databaseFactory,
_databasePathProvider = databasePathProvider ?? getDatabasesPath,
_databaseName = databaseName ?? _dbName;
final DatabaseFactory? _databaseFactory;
final Future<String> Function() _databasePathProvider;
final String _databaseName;
Database? _db; Database? _db;
Future<Database> get _database async { Future<Database> get _database async {
@@ -21,10 +33,27 @@ class ServicioFavoritos {
return _db!; return _db!;
} }
Future<void> cerrar() async {
await _db?.close();
_db = null;
}
Future<Database> _initDb() async { Future<Database> _initDb() async {
final dbPath = await getDatabasesPath(); final dbPath = await _databasePathProvider();
await Directory(dbPath).create(recursive: true); await Directory(dbPath).create(recursive: true);
final path = join(dbPath, _dbName); final path = join(dbPath, _databaseName);
final factory = _databaseFactory;
if (factory != null) {
return factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: _dbVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
onOpen: _asegurarEsquema,
),
);
}
return openDatabase( return openDatabase(
path, path,
version: _dbVersion, version: _dbVersion,
@@ -79,8 +108,8 @@ class ServicioFavoritos {
} }
} }
// Migraci?n defensiva: algunas instalaciones antiguas pueden venir de // Migración defensiva: algunas instalaciones antiguas pueden venir de
// esquemas intermedios. No asumimos qu? columna existe: la verificamos. // esquemas intermedios. No asumimos qué columna existe: la verificamos.
await addColumn('favicon', 'TEXT'); await addColumn('favicon', 'TEXT');
await addColumn('pais', 'TEXT'); await addColumn('pais', 'TEXT');
await addColumn('codigo_pais', 'TEXT'); await addColumn('codigo_pais', 'TEXT');
@@ -279,7 +308,7 @@ class ServicioFavoritos {
String _normalizarNombreGrupo(String nombre) { String _normalizarNombreGrupo(String nombre) {
final normalizado = nombre.trim().replaceAll(RegExp(r'\s+'), ' '); final normalizado = nombre.trim().replaceAll(RegExp(r'\s+'), ' ');
if (normalizado.isEmpty) { if (normalizado.isEmpty) {
throw ArgumentError('El nombre del grupo no puede estar vac?o'); throw ArgumentError('El nombre del grupo no puede estar vacío');
} }
if (normalizado.length > 28) { if (normalizado.length > 28) {
throw ArgumentError('El nombre del grupo no puede superar 28 caracteres'); throw ArgumentError('El nombre del grupo no puede superar 28 caracteres');
+16
View File
@@ -722,6 +722,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.6"
sqflite_common_ffi:
dependency: "direct dev"
description:
name: sqflite_common_ffi
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
url: "https://pub.dev"
source: hosted
version: "2.4.0+3"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -738,6 +746,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -59,6 +59,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
sqflite_common_ffi: ^2.3.7+1
flutter: flutter:
generate: true generate: true
@@ -0,0 +1,118 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/servicio_favoritos.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
late Directory tempDir;
setUpAll(() {
sqfliteFfiInit();
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('pluriwave_favoritos_');
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
ServicioFavoritos crearServicio() {
return ServicioFavoritos(
databaseFactory: databaseFactoryFfi,
databasePathProvider: () async => tempDir.path,
);
}
test('primera instalación crea esquema completo y guarda favoritos', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.agregar(_emisora('radio-1', 'Radio Uno'));
final favoritos = await servicio.obtenerTodos();
final grupos = await servicio.obtenerGrupos();
expect(favoritos, hasLength(1));
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos, hasLength(1));
expect(grupos.single.esSinAsignar, isTrue);
});
test('migra esquema antiguo sin grupo ni columnas nuevas', () async {
final dbPath = p.join(tempDir.path, 'pluriwave.db');
final db = await databaseFactoryFfi.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE favoritos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nombre TEXT NOT NULL,
url TEXT NOT NULL
)
''');
await db.insert('favoritos', {
'uuid': 'legacy-1',
'nombre': 'Legacy Radio',
'url': 'https://example.com/legacy.mp3',
});
},
),
);
await db.close();
final servicio = crearServicio();
addTearDown(servicio.cerrar);
final favoritos = await servicio.obtenerTodos();
final grupos = await servicio.obtenerGrupos();
expect(favoritos, hasLength(1));
expect(favoritos.single.uuid, 'legacy-1');
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos.singleWhere((g) => g.esSinAsignar).nombre, 'Sin asignar');
final grupo = await servicio.crearGrupo('Viajes');
await servicio.asignarGrupo('legacy-1', grupo.id);
expect(
(await servicio.obtenerTodos()).single.grupoFavoritosId,
grupo.id,
);
});
test('eliminar grupo reasigna sus favoritos a Sin asignar', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.agregar(_emisora('radio-1', 'Radio Uno'));
final grupo = await servicio.crearGrupo('Trabajo');
await servicio.asignarGrupo('radio-1', grupo.id);
await servicio.eliminarGrupo(grupo.id);
final favoritos = await servicio.obtenerTodos();
final grupos = await servicio.obtenerGrupos();
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos.any((g) => g.id == grupo.id), isFalse);
});
}
Emisora _emisora(String uuid, String nombre) {
return Emisora(
uuid: uuid,
nombre: nombre,
url: 'https://example.com/$uuid.mp3',
codec: 'MP3',
bitrate: 192,
);
}