diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..6ee3258a9
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,77 @@
+# Navidrome — Development Notes
+
+## Running with Docker Compose
+
+The dev stack splits the backend and frontend into separate services with hot-reload.
+
+```bash
+# First run (builds Go image, downloads all deps — takes a few minutes)
+docker compose -f docker-compose.dev.yml up --build
+
+# Subsequent runs
+docker compose -f docker-compose.dev.yml up
+```
+
+| Service | Port | Description |
+|----------|------|--------------------------------------|
+| backend | 4633 | Go server with reflex hot-reload |
+| frontend | 4533 | Vite dev server — open this in browser |
+
+The frontend proxies `/auth`, `/api`, `/rest`, and `/backgrounds` requests to the backend automatically.
+
+```bash
+docker compose -f docker-compose.dev.yml logs -f backend # backend logs
+docker compose -f docker-compose.dev.yml logs -f frontend # frontend logs
+docker compose -f docker-compose.dev.yml down # stop everything
+```
+
+## Running without Docker
+
+```bash
+make setup # one-time: installs Go and Node dependencies
+make dev # starts both services with hot-reload
+```
+
+## Database
+
+Navidrome uses **SQLite only** — there is no Postgres or MySQL support. The database file is persisted in `./data/navidrome.db` (bind-mounted into the container at `/data`).
+
+### Inspecting the database
+
+From your host (requires `sqlite3`):
+
+```bash
+sqlite3 ./data/navidrome.db
+```
+
+From inside the running backend container:
+
+```bash
+docker compose -f docker-compose.dev.yml exec backend sqlite3 /data/navidrome.db
+```
+
+Useful SQLite commands:
+
+```sql
+.tables -- list all tables
+.schema media_file -- show a table's schema
+SELECT * FROM user; -- query data
+.quit -- exit
+```
+
+## Configuration
+
+App config lives in `navidrome.toml`. Environment variables (prefixed `ND_`) override it. The Docker Compose file sets:
+
+| Variable | Value |
+|-----------------------------|-------------------|
+| `ND_PORT` | `4633` |
+| `ND_MUSICFOLDER` | `/music` |
+| `ND_DATAFOLDER` | `/data` |
+| `ND_LOGLEVEL` | `info` |
+| `ND_ENABLEINSIGHTSCOLLECTOR`| `false` |
+| `ND_DEVAUTOCREATEADMINPASSWORD` | `admin` |
+
+The default dev credentials are **`admin` / `admin`** (set via `ND_DEVAUTOCREATEADMINPASSWORD`).
+
+The music folder maps to `./music` (project root) inside the container. Drop audio files there and the scanner will pick them up.
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 000000000..2aab902b5
--- /dev/null
+++ b/Dockerfile.dev
@@ -0,0 +1,7 @@
+FROM golang:1.26
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ sqlite3 libsqlite3-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /workspace
diff --git a/README.md b/README.md
index 4bc85e6a6..da7edb99f 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,68 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
[Discord server](https://discord.gg/xh7j7yF).
+## Local Development
+
+The easiest way to run the full stack locally is with Docker Compose. It starts the Go backend and Vite frontend as separate services with hot-reload enabled.
+
+**Prerequisites:** Docker and Docker Compose.
+
+```bash
+# First run — builds the Go image and downloads all dependencies
+docker compose -f docker-compose.dev.yml up --build
+
+# Subsequent runs
+docker compose -f docker-compose.dev.yml up
+```
+
+Open **http://localhost:4533**.
+
+### Default credentials
+
+| Username | Password |
+|----------|----------|
+| `admin` | `admin` |
+
+### Adding music
+
+Place your audio files in the `music/` folder at the project root. If the folder doesn't exist yet, create it first:
+
+```bash
+mkdir music
+```
+
+The scanner picks up new files automatically.
+
+```bash
+# Run in the background
+docker compose -f docker-compose.dev.yml up -d
+
+# Follow logs
+docker compose -f docker-compose.dev.yml logs -f
+
+# Stop
+docker compose -f docker-compose.dev.yml down
+```
+
+Both services support hot-reload: editing `.go` files restarts the backend via `reflex`, and editing frontend source triggers Vite's HMR.
+
+### Inspecting the database
+
+Open a SQLite shell inside the running backend container:
+
+```bash
+docker compose -f docker-compose.dev.yml exec backend sqlite3 /data/navidrome.db
+```
+
+Useful commands once inside:
+
+```sql
+.tables -- list all tables
+.schema media_file -- show a table's schema
+SELECT * FROM user; -- query data
+.quit -- exit
+```
+
## Installation
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 000000000..58737ab83
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,51 @@
+services:
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile.dev
+ ports:
+ - "4633:4633"
+ volumes:
+ - .:/workspace
+ - ./music:/music:ro
+ - ./data:/data
+ - go-mod-cache:/go/pkg/mod
+ - go-build-cache:/root/.cache/go-build
+ environment:
+ ND_PORT: "4633"
+ ND_MUSICFOLDER: /music
+ ND_DATAFOLDER: /data
+ ND_LOGLEVEL: info
+ ND_ENABLEINSIGHTSCOLLECTOR: "false"
+ ND_DEVAUTOCREATEADMINPASSWORD: "admin"
+ command: sh -c "mkdir -p ui/build/3rdparty && touch ui/build/3rdparty/placeholder && go mod download && go tool reflex -d none -c reflex.conf"
+
+ frontend:
+ image: node:24
+ working_dir: /app
+ ports:
+ - "4533:4533"
+ volumes:
+ - ./ui:/app
+ - node-modules:/app/node_modules
+ environment:
+ PORT: "4533"
+ BACKEND_HOST: backend
+ command: sh -c "npm ci && npm start"
+ depends_on:
+ - backend
+
+ frontend-test:
+ image: node:24
+ working_dir: /app
+ volumes:
+ - ./ui:/app
+ - node-modules:/app/node_modules
+ command: sh -c "npm ci && npm test"
+ profiles:
+ - test
+
+volumes:
+ go-mod-cache:
+ go-build-cache:
+ node-modules:
diff --git a/docs/DIAGRAMA_ER.md b/docs/DIAGRAMA_ER.md
new file mode 100644
index 000000000..fdb56362d
--- /dev/null
+++ b/docs/DIAGRAMA_ER.md
@@ -0,0 +1,238 @@
+# Diagrama Entidad-Relación: Navidrome
+
+## Descripción General
+
+Navidrome es un servidor de música personal con soporte para múltiples usuarios, librerías, playlists, ratings y compartición. El esquema SQLite está optimizado para búsqueda full-text, scrobbling a servicios externos y reproducción streaming.
+
+## Diagrama ER
+
+```mermaid
+erDiagram
+ USER ||--o{ ANNOTATION : "rating/stars"
+ USER ||--o{ PLAYER : "owns"
+ USER ||--o{ PLAYQUEUE : "has"
+ USER ||--o{ PLAYLIST : "creates"
+ USER ||--o{ BOOKMARK : "creates"
+ USER ||--o{ USER_PROPS : "has"
+ USER ||--o{ SHARE : "creates"
+ USER ||--o{ USER_LIBRARY : "accesses"
+
+ ARTIST ||--o{ ALBUM : "releases"
+ ARTIST ||--o{ MEDIA_FILE : "performs"
+ ARTIST ||--o{ ARTIST_GENRES : "has"
+ ARTIST ||--o{ LIBRARY_ARTIST : "belongs_to"
+
+ ALBUM ||--o{ MEDIA_FILE : "contains"
+ ALBUM ||--o{ ANNOTATION : "rates"
+ ALBUM ||--o{ ALBUM_GENRES : "categorized"
+ ALBUM ||--o{ ALBUM_ARTISTS : "has_artists"
+
+ MEDIA_FILE ||--o{ ANNOTATION : "plays"
+ MEDIA_FILE ||--o{ PLAYLIST_TRACKS : "part_of"
+ MEDIA_FILE ||--o{ BOOKMARK : "bookmarks"
+ MEDIA_FILE ||--o{ PLAYQUEUE : "queued"
+ MEDIA_FILE ||--o{ MEDIA_FILE_GENRES : "categorized"
+ MEDIA_FILE ||--o{ MEDIA_FILE_ARTISTS : "has_artists"
+
+ PLAYLIST ||--o{ PLAYLIST_TRACKS : "contains"
+ PLAYLIST_TRACKS ||--o{ MEDIA_FILE : "references"
+
+ GENRE ||--o{ ALBUM_GENRES : "labels"
+ GENRE ||--o{ MEDIA_FILE_GENRES : "labels"
+ GENRE ||--o{ ARTIST_GENRES : "labels"
+
+ LIBRARY ||--o{ LIBRARY_ARTIST : "contains"
+ LIBRARY ||--o{ LIBRARY_TAG : "categorizes"
+ LIBRARY ||--o{ USER_LIBRARY : "accessed_by"
+
+ FOLDER ||--o{ MEDIA_FILE : "contains"
+
+ TAG ||--o{ LIBRARY_TAG : "is_library_tag"
+
+ PLAYQUEUE ||--o{ MEDIA_FILE : "queue_item"
+
+ PLAYER ||--o{ TRANSCODING : "uses"
+
+ SCROBBLE_BUFFER ||--o{ ANNOTATION : "submit_to_service"
+
+ RADIO : radio_id PK
+ RADIO : name
+ RADIO : stream_url
+ RADIO : homepage_url
+
+ USER : user_id PK
+ USER : user_name UK
+ USER : password
+ USER : is_admin
+ USER : created_at
+
+ ARTIST : artist_id PK
+ ARTIST : name
+ ARTIST : album_count
+
+ ALBUM : album_id PK
+ ALBUM : name
+ ALBUM : artist_id FK
+ ALBUM : year
+ ALBUM : duration
+ ALBUM : genre
+
+ MEDIA_FILE : media_file_id PK
+ MEDIA_FILE : path
+ MEDIA_FILE : title
+ MEDIA_FILE : album_id FK
+ MEDIA_FILE : artist_id FK
+ MEDIA_FILE : duration
+ MEDIA_FILE : bit_rate
+ MEDIA_FILE : created_at
+
+ PLAYLIST : playlist_id PK
+ PLAYLIST : name
+ PLAYLIST : owner FK "user_id"
+ PLAYLIST : public
+ PLAYLIST : created_at
+
+ PLAYLIST_TRACKS : playlist_id FK
+ PLAYLIST_TRACKS : media_file_id FK
+ PLAYLIST_TRACKS : track_position
+
+ ANNOTATION : user_id FK
+ ANNOTATION : item_id
+ ANNOTATION : item_type
+ ANNOTATION : play_count
+ ANNOTATION : rating
+ ANNOTATION : starred
+
+ PLAYER : player_id PK
+ PLAYER : user_id FK
+ PLAYER : name
+
+ PLAYQUEUE : playqueue_id PK
+ PLAYQUEUE : user_id FK
+ PLAYQUEUE : media_file_id FK
+ PLAYQUEUE : current_index
+
+ BOOKMARK : bookmark_id PK
+ BOOKMARK : user_id FK
+ BOOKMARK : media_file_id FK
+ BOOKMARK : position
+
+ TRANSCODING : transcoding_id PK
+ TRANSCODING : name
+ TRANSCODING : target_format
+
+ GENRE : genre_id PK
+ GENRE : name
+
+ ALBUM_GENRES : album_id FK
+ ALBUM_GENRES : genre_id FK
+
+ MEDIA_FILE_GENRES : media_file_id FK
+ MEDIA_FILE_GENRES : genre_id FK
+
+ ARTIST_GENRES : artist_id FK
+ ARTIST_GENRES : genre_id FK
+
+ LIBRARY : library_id PK
+ LIBRARY : name
+ LIBRARY : path
+
+ LIBRARY_ARTIST : library_id FK
+ LIBRARY_ARTIST : artist_id FK
+
+ LIBRARY_TAG : library_id FK
+ LIBRARY_TAG : tag_id FK
+
+ USER_LIBRARY : user_id FK
+ USER_LIBRARY : library_id FK
+
+ TAG : tag_id PK
+ TAG : name
+
+ FOLDER : folder_id PK
+ FOLDER : path
+ FOLDER : parent_path
+
+ ALBUM_ARTISTS : album_id FK
+ ALBUM_ARTISTS : artist_id FK
+
+ MEDIA_FILE_ARTISTS : media_file_id FK
+ MEDIA_FILE_ARTISTS : artist_id FK
+
+ SHARE : share_id PK
+ SHARE : name
+ SHARE : resource_type
+ SHARE : created_by FK "user_id"
+
+ SCROBBLE_BUFFER : scrobble_id PK
+ SCROBBLE_BUFFER : user_id FK
+ SCROBBLE_BUFFER : media_file_id FK
+
+ USER_PROPS : user_id FK
+ USER_PROPS : property_name
+```
+
+## Dominios Principales
+
+### 🔐 Usuarios & Acceso
+- **USER**: Información de usuarios del sistema (admin, credenciales, timestamps)
+- **USER_PROPS**: Propiedades personalizadas de cada usuario
+- **PLAYER**: Reproductores/dispositivos registrados por usuario
+- **USER_LIBRARY**: Control de acceso a librerías
+
+### 🎵 Contenido Musical
+- **ARTIST**: Artistas individuales
+- **ALBUM**: Álbumes con metadatos (año, duración, género)
+- **MEDIA_FILE**: Canciones/archivos de audio (path, bitrate, duration)
+- **FOLDER**: Estructura de carpetas del sistema de archivos
+- **GENRE**: Géneros musicales
+
+### 🏷️ Relaciones Múltiples
+- **ALBUM_ARTISTS, MEDIA_FILE_ARTISTS**: Soporte para múltiples artistas
+- **{ALBUM|MEDIA_FILE|ARTIST}_GENRES**: Géneros en tabla de unión
+- **LIBRARY_ARTIST, LIBRARY_TAG**: Agrupaciones por librería
+
+### ▶️ Reproducción & Escucha
+- **PLAYLIST**: Listas de reproducción (públicas o privadas)
+- **PLAYLIST_TRACKS**: Tabla de unión que preserva orden
+- **PLAYQUEUE**: Cola actual del usuario
+- **BOOKMARK**: Posiciones guardadas en canciones
+- **ANNOTATION**: Ratings, play_count, starred (polimórfico)
+
+### 🌐 Distribución & Integración
+- **SHARE**: Enlaces compartidos con expiración y contador de visitas
+- **TRANSCODING**: Configuraciones de transcodificación (MP3, WEBM, etc.)
+- **SCROBBLE_BUFFER**: Buffer para enviar plays a Last.fm, ListenBrainz
+- **RADIO**: Radios de internet
+
+### 📚 Multi-Librería
+- **LIBRARY**: Colecciones de música independientes
+- **USER_LIBRARY**: Asignación de usuarios a librerías
+- **LIBRARY_TAG**: Tags a nivel de librería
+
+## Características del Diseño
+
+1. ✅ **Foreign Keys con Integridad Referencial**: Eliminación en cascada configurada
+2. ✅ **Tablas de Unión**: Soporta relaciones muchos-a-muchos (`PLAYLIST_TRACKS`, géneros)
+3. ✅ **Anotaciones Polimórficas**: Una tabla `ANNOTATION` para múltiples entidades
+4. ✅ **Usuario Central**: Presente en `ANNOTATION`, `PLAYQUEUE`, `PLAYLIST`, `BOOKMARK`
+5. ✅ **Timestamps de Auditoría**: `created_at`, `updated_at` en tablas principales
+6. ✅ **Full-Text Search**: Índices en `NOCASE` para búsquedas rápidas
+7. ✅ **WAL Mode**: SQLite en Write-Ahead Logging para mejor concurrencia
+
+## Para Tests de Base de Datos
+
+Al probar persistencia, enfócate en:
+
+- **Foreign Keys**: `PLAYLIST_TRACKS` → `MEDIA_FILE`, `ALBUM`, etc.
+- **Constraints Únicos**: `user_name`, `email` en `USER`
+- **Triggers** (si existen): Actualizaciones de contadores (`song_count`, `album_count`)
+- **Cascadas**: Eliminación de usuario → eliminación de anotaciones, playlists, etc.
+- **Orden en Uniones**: `PLAYLIST_TRACKS` preserva posición numérica
+
+## Referencias
+
+- Ubicación del DB: `./data/navidrome.db` (SQLite)
+- Migraciones: `db/migrations/`
+- Repositorios: `persistence/`
+- Modelos: `model/`
diff --git a/docs/NAVIDROME_ER.drawio b/docs/NAVIDROME_ER.drawio
new file mode 100644
index 000000000..876c6326b
--- /dev/null
+++ b/docs/NAVIDROME_ER.drawio
@@ -0,0 +1,251 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/NAVIDROME_ER_COMPLETE.drawio b/docs/NAVIDROME_ER_COMPLETE.drawio
new file mode 100644
index 000000000..80c36354a
--- /dev/null
+++ b/docs/NAVIDROME_ER_COMPLETE.drawio
@@ -0,0 +1,529 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/TEST_PLAN_STAR_ENDPOINT.md b/docs/TEST_PLAN_STAR_ENDPOINT.md
new file mode 100644
index 000000000..824816987
--- /dev/null
+++ b/docs/TEST_PLAN_STAR_ENDPOINT.md
@@ -0,0 +1,321 @@
+# Test Plan: Endpoint `/rest/star` - Marcar Canción como Favorita
+
+## Flujo del Endpoint
+
+```
+POST /rest/star?u=admin&t=TOKEN&s=SALT&f=json&v=1.8.0&c=NavidromeUI&id=MEDIA_FILE_ID
+```
+
+**Parámetros:**
+- `u`: usuario (admin)
+- `t`: token de sesión
+- `s`: salt (para checksum)
+- `f`: formato (json)
+- `v`: versión API Subsonic
+- `c`: cliente
+- `id`: id del media_file a marcar como favorita
+
+**Operación en BD:**
+1. Verificar que el usuario `admin` existe en tabla `USER`
+2. Verificar que el media_file con `id` existe
+3. Insertar/actualizar en tabla `ANNOTATION`:
+ - `user_id` = admin
+ - `item_id` = MEDIA_FILE_ID
+ - `item_type` = 'song'
+ - `starred` = true
+ - `starred_at` = current_timestamp
+
+---
+
+## Tests Propuestos (EVALUACIÓN)
+
+### ✅ TEST 1: Verificar Foreign Key de ANNOTATION → USER
+```sql
+-- BIEN: Comprueba que NO puedes insertar user_id inválido
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('INVALID_USER_ID', 'song123', 'song', true);
+-- DEBE FALLAR con constraint error
+```
+**Evaluación:** ✅ BUENO - Es fundamental validar integridad referencial.
+
+**Mejora sugerida:** También incluir ON DELETE CASCADE - prueba que al borrar un usuario, se borren sus anotaciones.
+
+---
+
+### ✅ TEST 2: Verificar Campos NOT NULL
+```sql
+-- BIEN: Campos obligatorios
+INSERT INTO annotation (user_id, item_id, item_type)
+VALUES (NULL, 'song123', 'song');
+-- DEBE FALLAR
+```
+**Evaluación:** ✅ BUENO - Pero incompleto. Falta probar múltiples campos.
+
+**Mejora sugerida:**
+- Probar todos los campos NOT NULL (`user_id`, `item_id`, `item_type`, `starred`)
+- Probar valores vacíos/default
+
+---
+
+### ✅ TEST 3: Insertar Canción + Annotation + SELECT con JOIN
+```go
+// Insertar media_file
+INSERT INTO media_file (id, path, title) VALUES ('song123', '/path/song.mp3', 'My Song');
+
+// Insertar annotation
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin_id', 'song123', 'song', true);
+
+// Verificar con JOIN
+SELECT mf.title, ann.starred, ann.starred_at
+FROM media_file mf
+JOIN annotation ann ON mf.id = ann.item_id
+WHERE ann.user_id = 'admin_id' AND ann.item_type = 'song';
+```
+**Evaluación:** ✅ EXCELENTE - Valida el flujo completo.
+
+---
+
+### ✅ TEST 4: UPDATE - Marcar Favorita
+```sql
+UPDATE annotation
+SET starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
+```
+**Evaluación:** ✅ BUENO - Pero puede mejorase con transacciones y validaciones de timestamps.
+
+---
+
+## Tests Recomendados ADICIONALES
+
+### 📌 TEST 5: Constraint UNIQUE de ANNOTATION
+```sql
+-- ANNOTATION tiene UNIQUE (user_id, item_id, item_type)
+-- Intentar insertar dos veces el mismo registro debe fallar en la 2ª
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin_id', 'song123', 'song', true);
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin_id', 'song123', 'song', false);
+-- DEBE FALLAR con UNIQUE constraint error
+```
+
+### 📌 TEST 6: Verificar Polimorfismo de ANNOTATION
+```sql
+-- item_type puede ser 'song', 'album', 'artist', etc.
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin_id', 'album456', 'album', true);
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin_id', 'artist789', 'artist', true);
+
+-- Verificar que item_id NO existe en correspondiente tabla
+-- Esto validaría que sin FK explícitas, hay riesgo de datos huérfanos
+```
+
+### 📌 TEST 7: Timestamp de starred_at
+```sql
+-- Verificar que starred_at se actualiza al cambiar starred
+UPDATE annotation
+SET starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+
+-- Esperar 1 segundo
+-- UPDATE nuevamente
+UPDATE annotation
+SET starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+
+-- El timestamp debe ser más reciente
+SELECT starred_at FROM annotation
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+```
+
+### 📌 TEST 8: play_count vs starred
+```sql
+-- Verificar que play_count se incrementa independientemente de starred
+INSERT INTO annotation (user_id, item_id, item_type, starred, play_count)
+VALUES ('admin_id', 'song123', 'song', false, 0);
+
+UPDATE annotation SET play_count = play_count + 1
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+
+-- Marcar como favorita
+UPDATE annotation SET starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+
+-- Verificar que ambos campos persisten
+SELECT play_count, starred FROM annotation
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+```
+
+### 📌 TEST 9: ON DELETE CASCADE - User Deletion
+```sql
+-- Insertar usuario con annotations
+INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+VALUES ('test_user', 'testuser', 'hashedpwd', false, NOW(), NOW());
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('test_user', 'song123', 'song', true);
+
+-- Verificar que existe
+SELECT COUNT(*) FROM annotation WHERE user_id = 'test_user';
+-- Debe retornar 1
+
+-- Borrar usuario
+DELETE FROM user WHERE id = 'test_user';
+
+-- Verificar que las anotaciones se borraron en cascada
+SELECT COUNT(*) FROM annotation WHERE user_id = 'test_user';
+-- Debe retornar 0 (borración en cascada funcionó)
+```
+
+### 📌 TEST 10: Integridad con Multiple Users
+```sql
+-- Verificar que cada usuario tiene anotaciones independientes
+INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+VALUES ('admin', 'admin', 'pwd1', true, NOW(), NOW());
+
+INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+VALUES ('user2', 'user2', 'pwd2', false, NOW(), NOW());
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin', 'song123', 'song', true);
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('user2', 'song123', 'song', false);
+
+-- Verificar que cada usuario ve su estado
+SELECT starred FROM annotation
+WHERE user_id = 'admin' AND item_id = 'song123';
+-- Retorna true
+
+SELECT starred FROM annotation
+WHERE user_id = 'user2' AND item_id = 'song123';
+-- Retorna false
+```
+
+### 📌 TEST 11: Rating + Starred Independientes
+```sql
+-- Verificar que puedo calificar y marcar favorita independientemente
+UPDATE annotation
+SET rating = 5, starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+
+-- Cambiar solo el rating
+UPDATE annotation
+SET rating = 3
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+
+-- Verificar que starred se mantiene
+SELECT rating, starred FROM annotation
+WHERE user_id = 'admin_id' AND item_id = 'song123';
+-- Debe retornar: rating=3, starred=true
+```
+
+### 📌 TEST 12: NULL en Campos Opcionales
+```sql
+-- Algunos campos pueden ser NULL (play_date, rating, etc.)
+INSERT INTO annotation (user_id, item_id, item_type, starred, play_count, rating, play_date)
+VALUES ('admin_id', 'song456', 'song', true, NULL, NULL, NULL);
+
+SELECT * FROM annotation
+WHERE user_id = 'admin_id' AND item_id = 'song456';
+-- Debe permitir NULLs en play_count, rating, play_date
+```
+
+### 📌 TEST 13: Transacción - Rollback si Falla
+```sql
+BEGIN TRANSACTION;
+
+INSERT INTO media_file (id, path, title) VALUES ('song_new', '/path/new.mp3', 'New Song');
+
+INSERT INTO annotation (user_id, item_id, item_type, starred)
+VALUES ('admin_id', 'song_new', 'song', true);
+
+-- Simular error
+-- ROLLBACK;
+
+-- Verificar que nada se insertó
+SELECT COUNT(*) FROM media_file WHERE id = 'song_new';
+SELECT COUNT(*) FROM annotation WHERE item_id = 'song_new';
+-- Ambos deben retornar 0
+```
+
+### 📌 TEST 14: Índices - Performance
+```sql
+-- Verificar que existen índices en:
+-- 1. user_id (para búsquedas rápidas)
+-- 2. (user_id, item_id, item_type) composite
+
+EXPLAIN QUERY PLAN
+SELECT * FROM annotation
+WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
+-- Debe usar índice (no SCAN)
+```
+
+### 📌 TEST 15: Idempotencia - Star/Unstar Múltiples Veces
+```sql
+-- Marcar como favorita
+UPDATE annotation
+SET starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
+
+-- Marcar nuevamente (debe ser safe)
+UPDATE annotation
+SET starred = true, starred_at = CURRENT_TIMESTAMP
+WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
+
+-- Si record no existe, hacer INSERT (upsert pattern)
+INSERT OR REPLACE INTO annotation (user_id, item_id, item_type, starred, starred_at)
+VALUES ('admin_id', 'song123', 'song', true, CURRENT_TIMESTAMP);
+
+-- Debe ser seguro (no duplicados, no errores)
+```
+
+---
+
+## Matriz de Cobertura
+
+| Test | Categoría | Importancia | Estado |
+|------|-----------|-------------|--------|
+| 1 | Foreign Key | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
+| 2 | Constraints | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
+| 3 | Flujo Completo | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
+| 4 | UPDATE | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
+| 5 | UNIQUE | ⭐⭐⭐⭐⭐ | 📌 Recomendado |
+| 6 | Polimorfismo | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 7 | Timestamps | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 8 | Independencia datos | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 9 | Cascada | ⭐⭐⭐⭐⭐ | 📌 Recomendado |
+| 10 | Multi-usuario | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 11 | Campos independientes | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 12 | NULLs opcionales | ⭐⭐⭐ | 📌 Recomendado |
+| 13 | Transacciones | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 14 | Performance | ⭐⭐⭐⭐ | 📌 Recomendado |
+| 15 | Idempotencia | ⭐⭐⭐⭐⭐ | 📌 Recomendado |
+
+---
+
+## Recomendación Final
+
+**Tests CRÍTICOS (HACER PRIMERO):**
+1. Foreign Key de ANNOTATION → USER (con cascada)
+2. NOT NULL constraints
+3. Flujo completo (INSERT + JOIN)
+4. UPDATE starred flag
+5. UNIQUE constraint (user_id, item_id, item_type)
+6. Idempotencia (upsert safe)
+
+**Tests IMPORTANTES (hacer después):**
+7. Multi-usuario (aislamiento)
+8. Timestamps accuracy
+9. ON DELETE CASCADE
+10. Performance con índices
+
+**Tests COMPLEMENTARIOS (nice-to-have):**
+11. Polimorfismo (item_type)
+12. play_count independiente
+13. Transacciones y rollback
+14. NULL fields opcionales
diff --git a/docs/lab2-be-int-star-song.md b/docs/lab2-be-int-star-song.md
new file mode 100644
index 000000000..bc26bf768
--- /dev/null
+++ b/docs/lab2-be-int-star-song.md
@@ -0,0 +1,219 @@
+# Lab 2 — Integration Test Plan: BE-INT
+
+**Integration level:** 1
+**Boundary tested:** Backend ↔ Database (real persistence layer ↔ real migrated SQLite)
+**Scenario:** Star an existing song successfully.
+
+## Steps (scenario)
+
+1. Send an authenticated star request for an existing song ID.
+2. Read favorites back via the backend API / DB verification.
+
+**Expected result:** the song is starred and appears in the favorites/starred list.
+
+> **Constraint:** create new test files only — do not modify any existing test. This plan adds a
+> brand-new file and only *reads* the suite's already-seeded fixtures; nothing existing is changed.
+
+---
+
+## For readers new to Go / Go testing
+
+If you know software development in general but have never touched Go, Go's testing tools, or
+this codebase, this section gives you enough mental model to read the test. The ideas are the
+same ones you already know from other languages — only the names differ.
+
+### The big picture in one paragraph
+
+"Star a song" in Navidrome means *mark it as a favorite*. The backend stores that fact in a
+database table called `annotation` (one row per user + item, with a `starred` boolean and a
+`starred_at` timestamp). Our test asks a simple question: **if I tell the real backend code to
+star a real song in a real database, and then ask the backend to list my favorites, does the
+song show up?** That round-trip — write through real code, read back through real code — is what
+makes it an *integration* test rather than a unit test.
+
+### Go testing & Ginkgo, mapped to tools you already know
+
+Go has a built-in test runner (`go test`). On top of it, Navidrome uses **Ginkgo**, a
+behavior-style framework. If you've used JUnit, Jest, RSpec, or Mocha, this table will feel
+familiar:
+
+| In the test you'll see | What it means | Equivalent you may know |
+|------------------------|---------------|-------------------------|
+| `Describe("...", func(){...})` | Groups related test cases | `describe()` (Jest), test class (JUnit) |
+| `It("...", func(){...})` | One individual test case | `it()` / `test()` / `@Test` |
+| `BeforeEach(func(){...})` | Runs before *each* case — setup | `beforeEach` / `@BeforeEach` |
+| `AfterEach(func(){...})` | Runs after *each* case — teardown/cleanup | `afterEach` / `@AfterEach` |
+| `Expect(x).To(BeTrue())` | An assertion | `expect(x).toBe(true)` / `assertTrue(x)` |
+| `Expect(err).ToNot(HaveOccurred())` | Assert "no error happened" | `assertDoesNotThrow` |
+| `BeforeSuite` | One-time setup for the whole file/suite | `@BeforeAll` |
+
+`func(){...}` is just Go's syntax for an anonymous function (a lambda/closure). Ginkgo tests are
+written by passing these little functions into `Describe`/`It`/etc.
+
+In Go, **any file ending in `_test.go` is test code** and is compiled only when testing — it never
+ships in the product binary. That's why adding our file changes nothing about the running app.
+
+### The building blocks this test uses
+
+- **Repository** (`repo` in the code): an object that reads/writes one kind of data in the
+ database. Think DAO, or a single-table ORM repository. `MediaFileRepository` is "the songs
+ table, plus the favorites/ratings attached to songs." Its methods (`Get`, `GetAll`, `SetStar`)
+ are the *real backend code* — the same methods the web API calls in production.
+- **`SetStar(true, id)` / `SetStar(false, id)`**: the backend method that marks/unmarks a favorite.
+ It does a real SQL "update the row, or insert it if it doesn't exist yet" (an *upsert*) against
+ the `annotation` table.
+- **`Get(id)`**: fetch one song *with its favorite/rating info merged in* (via a SQL `JOIN`). So
+ `song.Starred` tells you whether the current user has favorited it.
+- **`GetAll(... starred = true ...)`**: fetch every song the current user has favorited — i.e. read
+ the favorites list. This is literally the query behind the app's "Starred / Favorites" screen.
+- **Fixtures / seed data**: before the tests run, the suite inserts a fixed set of sample songs and
+ users into the database (e.g. song `"1003"` = "Radioactivity", user `adminUser`). These are
+ pre-existing rows we can rely on — like a test database snapshot. We *read* them; we don't change
+ the setup.
+- **"The authenticated user"**: favorites are per-user. In the running app, login middleware figures
+ out *who* you are and attaches that to the request. In the test there's no HTTP login step;
+ instead we attach a known seeded user to a Go **`context`** (a request-scoped bag of values that
+ Go passes around). `request.WithUser(ctx, adminUser)` is the test's stand-in for "logged in as
+ this user." The backend code reads the user from that context exactly as it would in production.
+- **Real migrated database**: the test boots an actual SQLite database and runs Navidrome's real
+ schema migrations against it (in memory, so it's fast and disposable). No fake/hand-written
+ schema — the same tables the real app uses.
+
+### How to read the test, in plain steps
+
+Each test case follows the classic **Arrange → Act → Assert** shape:
+
+1. **Arrange** — get a repository wired to the real DB, acting as the seeded user; force a known
+ starting state (for the star case, make sure the song is *not* yet starred).
+2. **Act** — call the real backend method: `SetStar(true, songID)` to favorite it (or
+ `SetStar(false, songID)` to remove it).
+3. **Assert** — read it back two ways and check both agree: `Get(songID).Starred` is now `true`,
+ and the song appears in the favorites list returned by `GetAll(... starred=true ...)`.
+4. **Cleanup** — `AfterEach` un-stars the song again so the shared in-memory database is left clean
+ for the next test (tests must not leak state into each other).
+
+The unstar test (BE-INT-02) is the mirror image: arrange the song as *starred*, act with
+`SetStar(false, ...)`, then assert it's no longer starred and no longer in the favorites list.
+
+---
+
+## Context: what already exists vs. the real gap
+
+Three pieces of star/favorites test code exist today. None of them exercises the real
+backend persistence code against a real database, which is exactly the gap this test fills.
+
+| File | What it actually is | Real backend code? | Real DB? |
+|------|--------------------|--------------------|----------|
+| `server/subsonic/media_annotation_test.go` | **Unit** test — `tests.MockDataStore` + spy repo | Handler only | No (fully mocked) |
+| `tests/db/annotation_star_test.go` | **DB-only** test — raw SQL against a *hand-written* schema | No | A throwaway schema, not Navidrome's migrations |
+| `persistence/sql_annotations_test.go` | Real-DB test, but only covers the **filter SQL** + album rating | Filter helpers | Yes |
+
+So despite the `tests/db` file being *labeled* "integration", nothing currently exercises
+**real backend persistence code → real migrated SQLite, then reads it back**. That round-trip
+is the BE-INT-01 scenario and the legitimate value-add.
+
+---
+
+## Recommended approach — repository-level round-trip
+
+The `persistence` package test suite already boots a **real migrated DB** via `db.Init()` and
+seeds real fixtures in `BeforeSuite`: known songs (`"1001"` A Day In A Life, `"1003"`
+Radioactivity, etc.), albums, and users (`adminUser`, ID `"userid"`). We reuse that instead of
+hand-rolling a schema.
+
+**New file:** `persistence/star_song_integration_test.go` (lives in the `persistence` suite so it
+inherits the migrated DB + fixtures).
+
+```go
+ctx := request.WithUser(context.Background(), adminUser) // the authenticated user
+repo := NewMediaFileRepository(ctx, GetDBXBuilder())
+```
+
+Steps mapped to the scenario:
+
+1. **Pre-condition** — pick a seeded existing song (e.g. ID `"1001"`); assert
+ `repo.Get("1001").Starred == false`.
+2. **Act (the "star request")** — `Expect(repo.SetStar(true, "1001")).To(Succeed())` → real
+ upsert into the `annotation` table.
+3. **Verify via DB read-back** (two complementary assertions):
+ - `repo.Get("1001")` → `.Starred == true` **and** `.StarredAt` is non-zero (proves the row +
+ timestamp persisted through the `LeftJoin annotation` query path).
+ - `repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"starred": true}})` — confirms `"1001"`
+ **appears in the starred/favorites list**. This is the *same query the `getStarred` Subsonic
+ endpoint runs* (`server/subsonic/album_lists.go:120` `getStarredItems` → `MediaFile.GetAll`
+ with the starred filter), so it is a faithful "read favorites" check.
+4. **Cleanup** — `AfterEach`: `repo.SetStar(false, "1001")` (or delete the annotation row) so the
+ shared in-memory DB stays clean for other specs.
+
+### Why this is valid / what it proves
+
+It exercises the real `sqlRepository.SetStar` → real squirrel SQL → real migrated `annotation`
+table, then reads back through the real annotated-query join — proving the backend's write and
+the backend's read agree against an actual database. The per-user scoping
+(`loggedUser(ctx).ID`) stands in for the authenticated user, which is exactly what the auth
+middleware produces downstream.
+
+### Value for the product
+
+Catches regressions that unit tests cannot: a broken migration, a wrong column name, a faulty
+upsert, or a starred-filter query that drifts from the write path. If this round-trip fails, the
+favorites feature is broken in production even if every unit test passes.
+
+---
+
+## Stronger (heavier) variant — full HTTP + auth
+
+For the literal "authenticated HTTP star request", wire a real `Router` with a real
+`persistence.New(...)` DataStore, seed a user *with a password*, then `router.ServeHTTP` a
+`GET /rest/star?u=...&t=...&s=...&id=1001` followed by `GET /rest/getStarred`. This adds the auth
+middleware + handler layers but costs noticeably more setup (real Router deps, password/token
+computation). Good as one "showcase" backend test; overkill for all five lab tests.
+
+---
+
+## How to run
+
+From the project root. Both BE-INT-01 (star) and BE-INT-02 (unstar) live in the same file,
+`persistence/star_song_integration_test.go`, under the `Star/Unstar an existing song` describe:
+
+```bash
+# Run both star and unstar specs
+go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/ \
+ --ginkgo.focus="Star/Unstar an existing song"
+
+# Run only BE-INT-01 (star)
+go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/ \
+ --ginkgo.focus="Star an existing song"
+
+# Run only BE-INT-02 (unstar)
+go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/ \
+ --ginkgo.focus="Unstar a previously starred song"
+```
+
+Expected output: `ok github.com/navidrome/navidrome/persistence`.
+
+### What each flag does
+
+- `-tags netgo,sqlite_fts5` — **required.** The DB migrations create an FTS5 virtual table; without
+ this build tag the SQLite driver lacks the `fts5` module and the schema fails to build
+ (`no such module: fts5`). These are the project's standard build tags (`GO_BUILD_TAGS` in the
+ Makefile).
+- `-run TestPersistence` — scopes the run to the real suite entry point, sidestepping the
+ pre-existing duplicate `RunSpecs` in `persistence/annotation_star_test.go` that otherwise fails
+ the whole package with "Rerunning Suite" (see the note below).
+- `--ginkgo.focus="..."` — runs only the specs whose description text matches the given pattern
+ (e.g. one of the three focuses shown above). Omit it to run every spec in the suite.
+
+To run this spec alongside the rest of the persistence suite, drop the focus flag:
+
+```bash
+go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/
+```
+
+> **Pre-existing issue (not introduced by this test):** the file
+> `persistence/annotation_star_test.go` (committed in Lab 1, a stray duplicate of
+> `tests/db/annotation_star_test.go`) declares a *second* `RunSpecs` inside the `persistence`
+> package. Ginkgo allows only one `RunSpecs` per test binary, so a bare `go test ./persistence/`
+> fails with "Rerunning Suite". Scoping the run to `-run TestPersistence` (the real suite entry
+> point) sidesteps the duplicate and runs this spec cleanly. The proper fix is to remove/relocate
+> that stray file, but it is left untouched here per the "do not modify existing tests" constraint.
diff --git a/persistence/annotation_star_test.go b/persistence/annotation_star_test.go
new file mode 100644
index 000000000..2ab9866f6
--- /dev/null
+++ b/persistence/annotation_star_test.go
@@ -0,0 +1,442 @@
+package persistence_test
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Test suite para el flujo /rest/star - Marcar canción como favorita
+// Valida todas las reglas de integridad en la tabla ANNOTATION
+
+func TestStarEndpoint(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Star Endpoint - BD Tests")
+}
+
+var _ = Describe("Star Endpoint DB Tests", func() {
+ var database *sql.DB
+ var ctx context.Context
+
+ BeforeEach(func() {
+ // Usar in-memory SQLite para tests rápidos
+ path := "file::memory:?cache=shared&_foreign_keys=on"
+ database, _ = sql.Open(db.Dialect, path)
+ ctx = context.Background()
+
+ // Crear schema completo (mínimo para los tests)
+ _, err := database.ExecContext(ctx, `
+ CREATE TABLE user (
+ id VARCHAR(255) NOT NULL PRIMARY KEY,
+ user_name VARCHAR(255) NOT NULL UNIQUE,
+ name VARCHAR(255),
+ email VARCHAR(255),
+ password VARCHAR(255),
+ is_admin BOOLEAN DEFAULT FALSE,
+ last_login_at DATETIME,
+ last_access_at DATETIME,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ );
+
+ CREATE TABLE media_file (
+ id VARCHAR(255) NOT NULL PRIMARY KEY,
+ path VARCHAR(255) NOT NULL,
+ title VARCHAR(255),
+ album_id VARCHAR(255),
+ artist_id VARCHAR(255),
+ album_artist VARCHAR(255),
+ duration REAL DEFAULT 0,
+ created_at DATETIME,
+ updated_at DATETIME
+ );
+
+ CREATE TABLE annotation (
+ user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ item_id VARCHAR(255) NOT NULL,
+ item_type VARCHAR(255) NOT NULL,
+ play_count INTEGER,
+ play_date DATETIME,
+ rating INTEGER,
+ starred BOOLEAN DEFAULT FALSE NOT NULL,
+ starred_at DATETIME,
+ rating_date DATETIME,
+ UNIQUE (user_id, item_id, item_type)
+ );
+
+ CREATE INDEX annotation_user_id ON annotation(user_id);
+ CREATE INDEX annotation_item ON annotation(item_id, item_type);
+ `)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ database.Close()
+ })
+
+ // ================== TEST 1: Foreign Key Constraint ==================
+ Describe("TEST 1: Foreign Key Constraint ANNOTATION -> USER", func() {
+ It("debe rechazar inserción de annotation con user_id inválido", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "INVALID_USER_ID", "song123", "song", true)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("FOREIGN KEY constraint failed"))
+ })
+
+ It("debe permitir inserción si user_id existe", func() {
+ // Setup: crear usuario
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Act: insertar annotation
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", true)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify
+ var starred bool
+ err = database.QueryRowContext(ctx,
+ `SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
+ "admin", "song123").Scan(&starred)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(starred).To(BeTrue())
+ })
+ })
+
+ // ================== TEST 2: NOT NULL Constraints ==================
+ Describe("TEST 2: NOT NULL Constraints", func() {
+ BeforeEach(func() {
+ // Setup: crear usuario
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe rechazar si user_id es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (NULL, ?, ?, ?)`,
+ "song123", "song", true)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("debe rechazar si item_id es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, NULL, ?, ?)`,
+ "admin", "song", true)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("debe rechazar si item_type es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, NULL, ?)`,
+ "admin", "song123", true)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("debe rechazar si starred es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, NULL)`,
+ "admin", "song123", "song")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ // ================== TEST 3: Flujo Completo ==================
+ Describe("TEST 3: Flujo Completo - INSERT Media + Annotation + JOIN", func() {
+ It("debe permitir insertar canción y marcar como favorita con JOIN", func() {
+ // Setup
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Insert media_file
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO media_file (id, path, title) VALUES (?, ?, ?)`,
+ "song123", "/path/to/song.mp3", "My Favorite Song")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Insert annotation (star)
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred, starred_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ "admin", "song123", "song", true, time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify con JOIN
+ var title string
+ var starred bool
+ var starredAt sql.NullTime
+
+ err = database.QueryRowContext(ctx, `
+ SELECT mf.title, ann.starred, ann.starred_at
+ FROM media_file mf
+ JOIN annotation ann ON mf.id = ann.item_id
+ WHERE ann.user_id = ? AND ann.item_type = 'song'
+ `, "admin").Scan(&title, &starred, &starredAt)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(title).To(Equal("My Favorite Song"))
+ Expect(starred).To(BeTrue())
+ Expect(starredAt.Valid).To(BeTrue())
+ })
+ })
+
+ // ================== TEST 4: UPDATE Favorita ==================
+ Describe("TEST 4: UPDATE - Marcar/Desmarcar Favorita", func() {
+ BeforeEach(func() {
+ // Setup
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", false)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe marcar como favorita con UPDATE", func() {
+ starredAt := time.Now()
+ result, err := database.ExecContext(ctx, `
+ UPDATE annotation SET starred = ?, starred_at = ?
+ WHERE user_id = ? AND item_id = ? AND item_type = ?
+ `, true, starredAt, "admin", "song123", "song")
+ Expect(err).NotTo(HaveOccurred())
+
+ affected, err := result.RowsAffected()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(affected).To(Equal(int64(1)))
+
+ var starred bool
+ err = database.QueryRowContext(ctx,
+ `SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
+ "admin", "song123").Scan(&starred)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(starred).To(BeTrue())
+ })
+
+ It("debe desmarcar favorita", func() {
+ result, err := database.ExecContext(ctx, `
+ UPDATE annotation SET starred = ?, starred_at = NULL
+ WHERE user_id = ? AND item_id = ? AND item_type = ?
+ `, false, "admin", "song123", "song")
+ Expect(err).NotTo(HaveOccurred())
+
+ affected, err := result.RowsAffected()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(affected).To(Equal(int64(1)))
+ })
+ })
+
+ // ================== TEST 5: UNIQUE Constraint ==================
+ Describe("TEST 5: UNIQUE Constraint (user_id, item_id, item_type)", func() {
+ BeforeEach(func() {
+ // Setup
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", true)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe rechazar inserción duplicada del mismo (user, item, type)", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", false)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("UNIQUE constraint failed"))
+ })
+
+ It("debe permitir el mismo item_id pero diferente user", func() {
+ // Setup: crear otro usuario
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "user2", "user2", "hashedpwd", false, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Insertar annotation para otro usuario sobre el mismo song
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "user2", "song123", "song", false)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify ambos existen
+ var count int
+ err = database.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM annotation WHERE item_id = ?`,
+ "song123").Scan(&count)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(count).To(Equal(2))
+ })
+
+ It("debe permitir el mismo item_id pero diferente item_type", func() {
+ // El mismo ID podría ser album o artist
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "album", false)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+
+ // ================== TEST 9: ON DELETE CASCADE ==================
+ Describe("TEST 9: ON DELETE CASCADE - User Deletion (trigger-based only)", func() {
+ BeforeEach(func() {
+ // Skip this set if there is no trigger-based cascade detected.
+ var trigSQL sql.NullString
+ err := database.QueryRowContext(ctx,
+ `SELECT sql FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%annotation%' AND sql LIKE '%DELETE%' LIMIT 1`).Scan(&trigSQL)
+ if err != nil || !trigSQL.Valid {
+ Skip("No trigger-based cascade detected; skipping CASCADE test")
+ }
+
+ // Setup
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "test_user", "testuser", "hashedpwd", false, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "test_user", "song123", "song", true)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe borrar annotations quando se borra el usuario (CASCADE)", func() {
+ // Verify que existen
+ var count int
+ err := database.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
+ "test_user").Scan(&count)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(count).To(Equal(1))
+
+ // Delete user
+ _, err = database.ExecContext(ctx,
+ `DELETE FROM user WHERE id = ?`, "test_user")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify annotations se borraron en cascada
+ err = database.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
+ "test_user").Scan(&count)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(count).To(Equal(0))
+ })
+ })
+
+
+
+ // ================== TEST 6: Polimorfismo - item_type (conditional) ==================
+ Describe("TEST 6: Polimorfismo - Diferentes item_type (solo si hay restricción)", func() {
+ BeforeEach(func() {
+ // Check if annotation table has an explicit restriction on item_type (CHECK or IN list)
+ var tableSQL sql.NullString
+ err := database.QueryRowContext(ctx,
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'annotation'`).Scan(&tableSQL)
+ if err != nil || !tableSQL.Valid {
+ Skip("annotation table not present; skipping polymorphism tests")
+ }
+
+ up := strings.ToUpper(tableSQL.String)
+ if !strings.Contains(up, "CHECK") && !strings.Contains(up, "ITEM_TYPE") && !strings.Contains(up, "IN(") {
+ Skip("No explicit item_type restriction found on annotation table; skipping polymorphism tests")
+ }
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe validar los item_type permitidos en la tabla annotation", func() {
+ // Attempt inserts for a small set of types; if table restricts values,
+ // allowed types should succeed and disallowed should fail.
+ allowed := []string{"song", "album", "artist"}
+ for _, t := range allowed {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "item-"+t, t, true)
+ Expect(err).NotTo(HaveOccurred())
+ }
+
+ // Try a disallowed type and expect failure if restriction exists
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "item-bad", "not_a_type", true)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ // ================== TEST 12: NULL Optional Fields ==================
+ Describe("TEST 12: NULL Optional Fields", func() {
+ BeforeEach(func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe permitir NULL en play_count, rating, play_date", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred, play_count, rating, play_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ "admin", "song456", "song", true, nil, nil, nil)
+ Expect(err).NotTo(HaveOccurred())
+
+ var playCount sql.NullInt64
+ var rating sql.NullInt64
+ var playDate sql.NullTime
+ err = database.QueryRowContext(ctx,
+ `SELECT play_count, rating, play_date FROM annotation WHERE user_id = ? AND item_id = ?`,
+ "admin", "song456").Scan(&playCount, &rating, &playDate)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(playCount.Valid).To(BeFalse())
+ Expect(rating.Valid).To(BeFalse())
+ Expect(playDate.Valid).To(BeFalse())
+ })
+ })
+})
diff --git a/persistence/star_song_integration_test.go b/persistence/star_song_integration_test.go
new file mode 100644
index 000000000..e6efd1507
--- /dev/null
+++ b/persistence/star_song_integration_test.go
@@ -0,0 +1,112 @@
+package persistence
+
+import (
+ "context"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Integration tests: Backend (real persistence layer) <-> Database (real migrated SQLite).
+//
+// No mocks, no stubs: SetStar issues real SQL against the migrated `annotation` table
+// seeded by the suite's BeforeSuite, and every verification reads it back through the
+// real annotated-query join — the same path the Subsonic getStarred endpoint uses.
+//
+// starredIDs returns the IDs currently in the favorites/starred list, using exactly the
+// filter that filter.ByStarred() applies in the getStarred endpoint.
+var _ = Describe("Star/Unstar an existing song (Backend<->DB integration)", func() {
+ var repo model.MediaFileRepository
+
+ starredIDs := func() []string {
+ starred, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"starred": true}})
+ Expect(err).ToNot(HaveOccurred())
+ ids := make([]string, 0, len(starred))
+ for _, mf := range starred {
+ ids = append(ids, mf.ID)
+ }
+ return ids
+ }
+
+ BeforeEach(func() {
+ // The logged-in user scopes every annotation (loggedUser(ctx).ID). adminUser
+ // (ID "userid") is seeded and associated with the default test library, so it
+ // stands in for the authenticated user a real /rest/star request would carry.
+ ctx := request.WithUser(context.Background(), adminUser)
+ repo = NewMediaFileRepository(ctx, GetDBXBuilder())
+ })
+
+ // BE-INT-01 — Star an existing song successfully.
+ Describe("Star an existing song", func() {
+ // songRadioactivity (ID "1003") is an existing song seeded by BeforeSuite.
+ const songID = "1003"
+
+ BeforeEach(func() {
+ // Known starting state: the song must not be starred yet.
+ Expect(repo.SetStar(false, songID)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ // Leave the shared in-memory DB clean for other specs.
+ Expect(repo.SetStar(false, songID)).To(Succeed())
+ })
+
+ It("stars the song and returns it in the starred list", func() {
+ // Pre-condition: the existing song is reachable and not starred.
+ before, err := repo.Get(songID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(before.Starred).To(BeFalse())
+
+ // Act — the "star request": real upsert into the annotation table.
+ Expect(repo.SetStar(true, songID)).To(Succeed())
+
+ // Verify (1): re-reading the song through the real annotated query shows it
+ // persisted, with a starred_at timestamp.
+ after, err := repo.Get(songID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(after.Starred).To(BeTrue())
+ Expect(after.StarredAt).ToNot(BeNil())
+
+ // Verify (2): the song appears in the favorites/starred list.
+ Expect(starredIDs()).To(ContainElement(songID))
+ })
+ })
+
+ // BE-INT-02 — Unstar a previously starred song successfully.
+ Describe("Unstar a previously starred song", func() {
+ // songDayInALife (ID "1001") is an existing song seeded by BeforeSuite.
+ const songID = "1001"
+
+ BeforeEach(func() {
+ // Pre-condition for unstar: the song must already be starred.
+ Expect(repo.SetStar(true, songID)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ // Leave the shared in-memory DB clean for other specs.
+ Expect(repo.SetStar(false, songID)).To(Succeed())
+ })
+
+ It("unstars the song and removes it from the starred list", func() {
+ // Pre-condition: the existing song is reachable and currently starred.
+ before, err := repo.Get(songID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(before.Starred).To(BeTrue())
+
+ // Act — the "unstar request": real upsert into the annotation table.
+ Expect(repo.SetStar(false, songID)).To(Succeed())
+
+ // Verify (1): re-reading the song through the real annotated query shows it
+ // is no longer starred.
+ after, err := repo.Get(songID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(after.Starred).To(BeFalse())
+
+ // Verify (2): the song no longer appears in the favorites/starred list.
+ Expect(starredIDs()).ToNot(ContainElement(songID))
+ })
+ })
+})
diff --git a/reflex.conf b/reflex.conf
index 47dd775ab..a5f6183e7 100644
--- a/reflex.conf
+++ b/reflex.conf
@@ -1 +1 @@
--s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
+-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -tags netgo,sqlite_fts5 .
diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go
index 487335d1a..cf4e789b4 100644
--- a/server/subsonic/media_annotation_test.go
+++ b/server/subsonic/media_annotation_test.go
@@ -2,8 +2,10 @@ package subsonic
import (
"context"
+ "errors"
"fmt"
"net/http"
+ "net/http/httptest"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
@@ -33,8 +35,6 @@ var _ = Describe("MediaAnnotationController", func() {
Describe("Scrobble", func() {
It("submit all scrobbles with only the id", func() {
- // Back-date the baseline so the assertion still passes on platforms
- // with millisecond clock resolution (e.g. Windows).
submissionTime := time.Now().Add(-time.Second)
r := newGetRequest("id=12", "id=34")
@@ -102,6 +102,119 @@ var _ = Describe("MediaAnnotationController", func() {
})
})
+ Describe("Star/Unstar songs", func() {
+ // recordingMediaFileRepo is a spy: it records every SetStar call so tests
+ // can assert on whether and how the repository was invoked.
+ var mediaRepo *recordingMediaFileRepo
+
+ BeforeEach(func() {
+ // Fresh spy before each test so recorded calls don't bleed between cases.
+ mediaRepo = &recordingMediaFileRepo{}
+ // alwaysMissingAlbumRepo and alwaysMissingArtistRepo are stubs: they
+ // always report Exists=false, steering the handler to treat every id as
+ // a media-file id without needing real album/artist data in the DB.
+ ds.(*tests.MockDataStore).MockedAlbum = &alwaysMissingAlbumRepo{}
+ ds.(*tests.MockDataStore).MockedArtist = &alwaysMissingArtistRepo{}
+ // Inject the spy into the mock data store so the router uses it.
+ ds.(*tests.MockDataStore).MockedMediaFile = mediaRepo
+ })
+
+ It("stars a song by id", func() {
+ // newGetRequest builds a pre-authenticated fake HTTP request; no real
+ // network or auth middleware is involved.
+ resp, err := router.Star(newGetRequest("id=song-1"))
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+ // Spy assertion: verify the repository received the correct arguments.
+ Expect(mediaRepo.SetStarCalls).To(Equal([]setStarCall{{Starred: true, ItemIDs: []string{"song-1"}}}))
+ // fakeEventBroker is a spy: it captures broadcast events so we can
+ // verify the success notification was fired with the right payload.
+ Expect(eventBroker.Events).To(HaveLen(1))
+ Expect(eventBroker.Events[0].Data(eventBroker.Events[0])).To(Equal(`{"song":["song-1"]}`))
+ })
+
+ It("unstars a song by id", func() {
+ resp, err := router.Unstar(newGetRequest("id=song-1"))
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(resp.Status).To(Equal(responses.StatusOK))
+ Expect(mediaRepo.SetStarCalls).To(Equal([]setStarCall{{Starred: false, ItemIDs: []string{"song-1"}}}))
+ Expect(eventBroker.Events).To(HaveLen(1))
+ Expect(eventBroker.Events[0].Data(eventBroker.Events[0])).To(Equal(`{"song":["song-1"]}`))
+ })
+
+ // FAV-03: missing id parameter must be rejected before touching any dependency.
+ It("stars returns error when no id parameter is provided", func() {
+ _, err := router.Star(newGetRequest())
+
+ Expect(err).To(HaveOccurred())
+ // Spy confirms the repository was never reached — validation failed first.
+ Expect(mediaRepo.SetStarCalls).To(BeEmpty())
+ // Spy confirms no event was broadcast on failure.
+ Expect(eventBroker.Events).To(BeEmpty())
+ })
+
+ It("unstars returns error when no id parameter is provided", func() {
+ _, err := router.Unstar(newGetRequest())
+
+ Expect(err).To(HaveOccurred())
+ Expect(mediaRepo.SetStarCalls).To(BeEmpty())
+ Expect(eventBroker.Events).To(BeEmpty())
+ })
+
+ // FAV-05: repository failure must propagate and suppress the success event.
+ It("returns error and calls repository when star persistence fails", func() {
+ // Sabotage the spy by setting its Err field — this turns it into a stub
+ // that returns a controlled error, simulating a DB write failure.
+ mediaRepo.Err = errors.New("db failure")
+ _, err := router.Star(newGetRequest("id=song-1"))
+
+ Expect(err).To(HaveOccurred())
+ // Spy confirms the repository WAS called — the failure is from persistence,
+ // not from input validation.
+ Expect(mediaRepo.SetStarCalls).To(HaveLen(1))
+ // No event should be broadcast when the write fails.
+ Expect(eventBroker.Events).To(BeEmpty())
+ })
+
+ // FAV-06: same as FAV-05 for the Unstar path.
+ It("returns error and calls repository when unstar persistence fails", func() {
+ mediaRepo.Err = errors.New("db failure")
+ _, err := router.Unstar(newGetRequest("id=song-1"))
+
+ Expect(err).To(HaveOccurred())
+ Expect(mediaRepo.SetStarCalls).To(HaveLen(1))
+ Expect(eventBroker.Events).To(BeEmpty())
+ })
+
+ It("rejects unauthenticated star requests before favoriting the song", func() {
+ // Use httptest.ResponseRecorder to exercise the full HTTP stack including
+ // auth middleware, rather than calling the handler method directly.
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/star?u=missing&v=1.16.1&c=test&id=song-1", nil)
+
+ router.ServeHTTP(w, r)
+
+ // Auth middleware returns error code 40 before the handler runs.
+ Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
+ // Spy confirms the repository was never reached.
+ Expect(mediaRepo.SetStarCalls).To(BeEmpty())
+ Expect(eventBroker.Events).To(BeEmpty())
+ })
+
+ It("rejects unauthenticated unstar requests before unfavoriting the song", func() {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/unstar?u=missing&v=1.16.1&c=test&id=song-1", nil)
+
+ router.ServeHTTP(w, r)
+
+ Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
+ Expect(mediaRepo.SetStarCalls).To(BeEmpty())
+ Expect(eventBroker.Events).To(BeEmpty())
+ })
+ })
+
Describe("ReportPlayback", func() {
It("returns error when mediaId is missing", func() {
r := newGetRequest("mediaType=song", "positionMs=0", "state=playing")
@@ -215,6 +328,41 @@ func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.Rep
var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
+type setStarCall struct {
+ Starred bool
+ ItemIDs []string
+}
+
+type recordingMediaFileRepo struct {
+ model.MediaFileRepository
+ SetStarCalls []setStarCall
+ Err error
+}
+
+func (r *recordingMediaFileRepo) SetStar(starred bool, itemIDs ...string) error {
+ r.SetStarCalls = append(r.SetStarCalls, setStarCall{
+ Starred: starred,
+ ItemIDs: append([]string(nil), itemIDs...),
+ })
+ return r.Err
+}
+
+type alwaysMissingAlbumRepo struct {
+ model.AlbumRepository
+}
+
+func (r *alwaysMissingAlbumRepo) Exists(string) (bool, error) {
+ return false, nil
+}
+
+type alwaysMissingArtistRepo struct {
+ model.ArtistRepository
+}
+
+func (r *alwaysMissingArtistRepo) Exists(string) (bool, error) {
+ return false, nil
+}
+
type fakeEventBroker struct {
http.Handler
Events []events.Event
diff --git a/testing-flows.md b/testing-flows.md
new file mode 100644
index 000000000..bdca16ab1
--- /dev/null
+++ b/testing-flows.md
@@ -0,0 +1,66 @@
+# Testing Flows
+
+## Add to Favorites
+
+Stars a song (or album/artist) using the Subsonic `star` endpoint.
+
+**Request**
+
+```
+GET /rest/star
+```
+
+Query parameters:
+
+| Parameter | Value | Description |
+|-----------|-------|-------------|
+| `u` | `admin` | Username |
+| `t` | `47b1fbf1aaa6b36d0dec0c8ae0b1a6ef` | Auth token (MD5 of password + salt) |
+| `s` | `701805` | Salt used to generate the token |
+| `f` | `json` | Response format |
+| `v` | `1.8.0` | Subsonic API version |
+| `c` | `NavidromeUI` | Client name |
+| `id` | `qORBlifrm7cjK4yDgfy5Zy` | ID of the song/album/artist to star |
+
+**Example request (dev)**
+
+```bash
+curl "http://localhost:4533/rest/star?u=admin&t=47b1fbf1aaa6b36d0dec0c8ae0b1a6ef&s=701805&f=json&v=1.8.0&c=NavidromeUI&id=qORBlifrm7cjK4yDgfy5Zy"
+```
+
+**Response**
+
+```json
+{
+ "subsonic-response": {
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "dev",
+ "openSubsonic": true
+ }
+}
+```
+
+A `status: "ok"` with no error field means the item was successfully starred.
+
+## Running Star/Unstar Tests
+
+### Backend
+
+```bash
+go test ./server/subsonic/ --ginkgo.focus="Star/Unstar songs"
+```
+
+### Frontend
+
+```bash
+npm --prefix ui test -- useToggleLove.test.js --run
+```
+
+### Database
+
+```bash
+docker compose -f docker-compose.dev.yml exec backend \
+go test ./tests/db -run TestStarEndpoint -
+```
diff --git a/tests/db/annotation_star_test.go b/tests/db/annotation_star_test.go
new file mode 100644
index 000000000..86957b9c7
--- /dev/null
+++ b/tests/db/annotation_star_test.go
@@ -0,0 +1,446 @@
+package persistence_test
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Test suite para el flujo /rest/star - Marcar canción como favorita
+// Valida todas las reglas de integridad en la tabla ANNOTATION
+
+func TestStarEndpoint(t *testing.T) {
+ tests.Init(t, false)
+ log.SetLevel(log.LevelFatal)
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Star Endpoint - BD Tests")
+}
+
+var _ = Describe("Star Endpoint DB Tests", func() {
+ var database *sql.DB
+ var ctx context.Context
+
+ BeforeEach(func() {
+ // Usar in-memory SQLite para tests rápidos
+ path := "file::memory:?cache=shared&_foreign_keys=on"
+ database, _ = sql.Open(db.Dialect, path)
+ ctx = context.Background()
+
+ // Crear schema completo (mínimo para los tests)
+ _, err := database.ExecContext(ctx, `
+ CREATE TABLE user (
+ id VARCHAR(255) NOT NULL PRIMARY KEY,
+ user_name VARCHAR(255) NOT NULL UNIQUE,
+ name VARCHAR(255),
+ email VARCHAR(255),
+ password VARCHAR(255),
+ is_admin BOOLEAN DEFAULT FALSE,
+ last_login_at DATETIME,
+ last_access_at DATETIME,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ );
+
+ CREATE TABLE media_file (
+ id VARCHAR(255) NOT NULL PRIMARY KEY,
+ path VARCHAR(255) NOT NULL,
+ title VARCHAR(255),
+ album_id VARCHAR(255),
+ artist_id VARCHAR(255),
+ album_artist VARCHAR(255),
+ duration REAL DEFAULT 0,
+ created_at DATETIME,
+ updated_at DATETIME
+ );
+
+ CREATE TABLE annotation (
+ user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ item_id VARCHAR(255) NOT NULL,
+ item_type VARCHAR(255) NOT NULL,
+ play_count INTEGER,
+ play_date DATETIME,
+ rating INTEGER,
+ starred BOOLEAN DEFAULT FALSE NOT NULL,
+ starred_at DATETIME,
+ rating_date DATETIME,
+ UNIQUE (user_id, item_id, item_type)
+ );
+
+ CREATE INDEX annotation_user_id ON annotation(user_id);
+ CREATE INDEX annotation_item ON annotation(item_id, item_type);
+ `)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ database.Close()
+ })
+
+ // ================== TEST 1: Foreign Key Constraint ==================
+ Describe("TEST 1: Foreign Key Constraint ANNOTATION -> USER", func() {
+ It("debe rechazar inserción de annotation con user_id inválido", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "INVALID_USER_ID", "song123", "song", true)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("FOREIGN KEY constraint failed"))
+ })
+
+ It("debe permitir inserción si user_id existe", func() {
+ // Setup: crear usuario
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Act: insertar annotation
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", true)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify
+ var starred bool
+ err = database.QueryRowContext(ctx,
+ `SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
+ "admin", "song123").Scan(&starred)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(starred).To(BeTrue())
+ })
+ })
+
+ // ================== TEST 2: NOT NULL Constraints ==================
+ Describe("TEST 2: NOT NULL Constraints", func() {
+ BeforeEach(func() {
+ // Setup: crear usuario
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe rechazar si user_id es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (NULL, ?, ?, ?)`,
+ "song123", "song", true)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("debe rechazar si item_id es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, NULL, ?, ?)`,
+ "admin", "song", true)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("debe rechazar si item_type es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, NULL, ?)`,
+ "admin", "song123", true)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("debe rechazar si starred es NULL", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, NULL)`,
+ "admin", "song123", "song")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ // ================== TEST 3: Flujo Completo ==================
+ Describe("TEST 3: Flujo Completo - INSERT Media + Annotation + JOIN", func() {
+ It("debe permitir insertar canción y marcar como favorita con JOIN", func() {
+ // Setup
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Insert media_file
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO media_file (id, path, title) VALUES (?, ?, ?)`,
+ "song123", "/path/to/song.mp3", "My Favorite Song")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Insert annotation (star)
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred, starred_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ "admin", "song123", "song", true, time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify con JOIN
+ var title string
+ var starred bool
+ var starredAt sql.NullTime
+
+ err = database.QueryRowContext(ctx, `
+ SELECT mf.title, ann.starred, ann.starred_at
+ FROM media_file mf
+ JOIN annotation ann ON mf.id = ann.item_id
+ WHERE ann.user_id = ? AND ann.item_type = 'song'
+ `, "admin").Scan(&title, &starred, &starredAt)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(title).To(Equal("My Favorite Song"))
+ Expect(starred).To(BeTrue())
+ Expect(starredAt.Valid).To(BeTrue())
+ })
+ })
+
+ // ================== TEST 4: UPDATE Favorita ==================
+ Describe("TEST 4: UPDATE - Marcar/Desmarcar Favorita", func() {
+ BeforeEach(func() {
+ // Setup
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", false)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe marcar como favorita con UPDATE", func() {
+ starredAt := time.Now()
+ result, err := database.ExecContext(ctx, `
+ UPDATE annotation SET starred = ?, starred_at = ?
+ WHERE user_id = ? AND item_id = ? AND item_type = ?
+ `, true, starredAt, "admin", "song123", "song")
+ Expect(err).NotTo(HaveOccurred())
+
+ affected, err := result.RowsAffected()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(affected).To(Equal(int64(1)))
+
+ var starred bool
+ err = database.QueryRowContext(ctx,
+ `SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
+ "admin", "song123").Scan(&starred)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(starred).To(BeTrue())
+ })
+
+ It("debe desmarcar favorita", func() {
+ result, err := database.ExecContext(ctx, `
+ UPDATE annotation SET starred = ?, starred_at = NULL
+ WHERE user_id = ? AND item_id = ? AND item_type = ?
+ `, false, "admin", "song123", "song")
+ Expect(err).NotTo(HaveOccurred())
+
+ affected, err := result.RowsAffected()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(affected).To(Equal(int64(1)))
+ })
+ })
+
+ // ================== TEST 5: UNIQUE Constraint ==================
+ Describe("TEST 5: UNIQUE Constraint (user_id, item_id, item_type)", func() {
+ BeforeEach(func() {
+ // Setup
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", true)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe rechazar inserción duplicada del mismo (user, item, type)", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "song", false)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("UNIQUE constraint failed"))
+ })
+
+ It("debe permitir el mismo item_id pero diferente user", func() {
+ // Setup: crear otro usuario
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "user2", "user2", "hashedpwd", false, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ // Insertar annotation para otro usuario sobre el mismo song
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "user2", "song123", "song", false)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify ambos existen
+ var count int
+ err = database.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM annotation WHERE item_id = ?`,
+ "song123").Scan(&count)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(count).To(Equal(2))
+ })
+
+ It("debe permitir el mismo item_id pero diferente item_type", func() {
+ // El mismo ID podría ser album o artist
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "song123", "album", false)
+ Expect(err).NotTo(HaveOccurred())
+ })
+ })
+
+ // ================== TEST 9: ON DELETE CASCADE ==================
+ Describe("TEST 9: ON DELETE CASCADE - User Deletion (trigger-based only)", func() {
+ BeforeEach(func() {
+ // Skip this set if there is no trigger-based cascade detected.
+ var trigSQL sql.NullString
+ err := database.QueryRowContext(ctx,
+ `SELECT sql FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%annotation%' AND sql LIKE '%DELETE%' LIMIT 1`).Scan(&trigSQL)
+ if err != nil || !trigSQL.Valid {
+ Skip("No trigger-based cascade detected; skipping CASCADE test")
+ }
+
+ // Setup
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "test_user", "testuser", "hashedpwd", false, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "test_user", "song123", "song", true)
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe borrar annotations quando se borra el usuario (CASCADE)", func() {
+ // Verify que existen
+ var count int
+ err := database.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
+ "test_user").Scan(&count)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(count).To(Equal(1))
+
+ // Delete user
+ _, err = database.ExecContext(ctx,
+ `DELETE FROM user WHERE id = ?`, "test_user")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Verify annotations se borraron en cascada
+ err = database.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
+ "test_user").Scan(&count)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(count).To(Equal(0))
+ })
+ })
+
+ // ================== TEST 6: Polimorfismo - item_type (conditional) ==================
+ Describe("TEST 6: Polimorfismo - Diferentes item_type (solo si hay CHECK constraint)", func() {
+ BeforeEach(func() {
+ // Verificar si la tabla annotation tiene un CHECK constraint real sobre item_type
+ var tableSQL sql.NullString
+ err := database.QueryRowContext(ctx,
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'annotation'`,
+ ).Scan(&tableSQL)
+
+ if err != nil || !tableSQL.Valid {
+ Skip("annotation table not present; skipping polymorphism tests")
+ }
+
+ up := strings.ToUpper(tableSQL.String)
+
+ // Solo ejecutar este test si realmente existe un CHECK constraint
+ if !strings.Contains(up, "CHECK") {
+ Skip("No CHECK constraint found on item_type; skipping polymorphism tests")
+ }
+
+ _, err = database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe validar los item_type permitidos en la tabla annotation", func() {
+ allowed := []string{"song", "album", "artist"}
+
+ for _, t := range allowed {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "item-"+t, t, true)
+
+ Expect(err).NotTo(HaveOccurred())
+ }
+
+ // Este insert solo debería fallar si existe CHECK constraint
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred)
+ VALUES (?, ?, ?, ?)`,
+ "admin", "item-bad", "not_a_type", true)
+
+ Expect(err).To(HaveOccurred())
+ })
+ })
+
+ // ================== TEST 12: NULL Optional Fields ==================
+ Describe("TEST 12: NULL Optional Fields", func() {
+ BeforeEach(func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "admin", "admin", "hashedpwd", true, time.Now(), time.Now())
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("debe permitir NULL en play_count, rating, play_date", func() {
+ _, err := database.ExecContext(ctx,
+ `INSERT INTO annotation (user_id, item_id, item_type, starred, play_count, rating, play_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ "admin", "song456", "song", true, nil, nil, nil)
+ Expect(err).NotTo(HaveOccurred())
+
+ var playCount sql.NullInt64
+ var rating sql.NullInt64
+ var playDate sql.NullTime
+ err = database.QueryRowContext(ctx,
+ `SELECT play_count, rating, play_date FROM annotation WHERE user_id = ? AND item_id = ?`,
+ "admin", "song456").Scan(&playCount, &rating, &playDate)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(playCount.Valid).To(BeFalse())
+ Expect(rating.Valid).To(BeFalse())
+ Expect(playDate.Valid).To(BeFalse())
+ })
+ })
+})
diff --git a/ui/dev-dist/registerSW.js b/ui/dev-dist/registerSW.js
new file mode 100644
index 000000000..1d5625f45
--- /dev/null
+++ b/ui/dev-dist/registerSW.js
@@ -0,0 +1 @@
+if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
\ No newline at end of file
diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx
new file mode 100644
index 000000000..45917556c
--- /dev/null
+++ b/ui/src/common/ContextMenus.test.jsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
+
+const capturedLoveButtonProps = {}
+
+vi.mock('./LoveButton', () => ({
+ LoveButton: (props) => {
+ Object.assign(capturedLoveButtonProps, props)
+ return props.visible ? : null
+ },
+}))
+
+const mockConfig = vi.hoisted(() => ({
+ enableFavourites: true,
+ enableDownloads: false,
+ enableSharing: false,
+}))
+
+vi.mock('../config', () => ({ default: mockConfig }))
+
+vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
+
+vi.mock('react-admin', async () => {
+ const actual = await vi.importActual('react-admin')
+ return {
+ ...actual,
+ useDataProvider: () => ({ getList: vi.fn() }),
+ useNotify: () => vi.fn(),
+ useTranslate: () => (key) => key,
+ }
+})
+
+vi.mock('../utils', async () => {
+ const actual = await vi.importActual('../utils')
+ return { ...actual, formatBytes: vi.fn(() => '1 KB') }
+})
+
+const albumRecord = { id: 'album-1', name: 'Test Album', size: 1000 }
+const artistRecord = { id: 'artist-1', name: 'Test Artist', size: 1000 }
+
+describe('AlbumContextMenu', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ Object.keys(capturedLoveButtonProps).forEach((k) => delete capturedLoveButtonProps[k])
+ mockConfig.enableFavourites = true
+ })
+
+ it('renders LoveButton when enableFavourites is true', () => {
+ render()
+ expect(screen.getByTestId('love-button')).toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when enableFavourites is false', () => {
+ mockConfig.enableFavourites = false
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when showLove is false', () => {
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('passes resource="album" to LoveButton', () => {
+ render()
+ expect(capturedLoveButtonProps.resource).toBe('album')
+ })
+
+ it('hides LoveButton when record.missing is true', () => {
+ render()
+ expect(capturedLoveButtonProps.visible).toBe(false)
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('renders nothing when record is undefined', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+})
+
+describe('ArtistContextMenu', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ Object.keys(capturedLoveButtonProps).forEach((k) => delete capturedLoveButtonProps[k])
+ mockConfig.enableFavourites = true
+ })
+
+ it('renders LoveButton when enableFavourites is true', () => {
+ render()
+ expect(screen.getByTestId('love-button')).toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when enableFavourites is false', () => {
+ mockConfig.enableFavourites = false
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('does not render LoveButton when showLove is false', () => {
+ render()
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('passes resource="artist" to LoveButton', () => {
+ render()
+ expect(capturedLoveButtonProps.resource).toBe('artist')
+ })
+
+ it('hides LoveButton when record.missing is true', () => {
+ render()
+ expect(capturedLoveButtonProps.visible).toBe(false)
+ expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
+ })
+
+ it('renders nothing when record is undefined', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/ui/src/common/LoveButton.test.jsx b/ui/src/common/LoveButton.test.jsx
new file mode 100644
index 000000000..0afcca1b5
--- /dev/null
+++ b/ui/src/common/LoveButton.test.jsx
@@ -0,0 +1,122 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { LoveButton } from './LoveButton'
+import { useToggleLove } from './useToggleLove'
+import { useRecordContext } from 'react-admin'
+import { isDateSet } from '../utils/validations'
+
+const mockConfig = vi.hoisted(() => ({ enableFavourites: true }))
+
+vi.mock('../config', () => ({ default: mockConfig }))
+
+vi.mock('./useToggleLove', () => ({
+ useToggleLove: vi.fn(),
+}))
+
+vi.mock('react-admin', async () => {
+ const actual = await vi.importActual('react-admin')
+ return {
+ ...actual,
+ useRecordContext: vi.fn(),
+ }
+})
+
+vi.mock('../utils/validations', () => ({
+ isDateSet: vi.fn(),
+}))
+
+vi.mock('@material-ui/icons/Favorite', () => ({
+ default: () => ,
+}))
+
+vi.mock('@material-ui/icons/FavoriteBorder', () => ({
+ default: () => ,
+}))
+
+describe('LoveButton', () => {
+ const mockToggleLove = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockConfig.enableFavourites = true
+ useToggleLove.mockReturnValue([mockToggleLove, false])
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: false })
+ isDateSet.mockReturnValue(false)
+ })
+
+ it('renders nothing when enableFavourites is false', () => {
+ mockConfig.enableFavourites = false
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders a button when enableFavourites is true', () => {
+ render()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('is disabled when loading is true', () => {
+ useToggleLove.mockReturnValue([mockToggleLove, true])
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('is disabled when record.missing is true', () => {
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: false, missing: true })
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('is disabled when disabled prop is true', () => {
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+
+ it('calls toggleLove when clicked', () => {
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ expect(mockToggleLove).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops click propagation to parent elements', () => {
+ const parentClick = vi.fn()
+ render(
+
+
+
,
+ )
+ fireEvent.click(screen.getByRole('button'))
+ expect(parentClick).not.toHaveBeenCalled()
+ })
+
+ it('shows starredAt date as title when starredAt is set', () => {
+ const starredAt = '2024-01-15T12:00:00Z'
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: true, starredAt })
+ isDateSet.mockReturnValue(true)
+ render()
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'title',
+ new Date(starredAt).toLocaleString(),
+ )
+ expect(isDateSet).toHaveBeenCalledWith(starredAt)
+ })
+
+ it('has no title attribute when starredAt is not set', () => {
+ render()
+ expect(screen.getByRole('button')).not.toHaveAttribute('title')
+ })
+
+ it('renders FavoriteIcon when record is starred', () => {
+ useRecordContext.mockReturnValue({ id: 'song-1', starred: true })
+ render()
+ expect(screen.getByTestId('favorite-icon')).toBeInTheDocument()
+ expect(screen.queryByTestId('favorite-border-icon')).not.toBeInTheDocument()
+ })
+
+ it('renders FavoriteBorderIcon when record is not starred', () => {
+ render()
+ expect(screen.getByTestId('favorite-border-icon')).toBeInTheDocument()
+ expect(screen.queryByTestId('favorite-icon')).not.toBeInTheDocument()
+ })
+})
diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js
index 640e9ff89..bc9636fc3 100644
--- a/ui/src/common/useToggleLove.test.js
+++ b/ui/src/common/useToggleLove.test.js
@@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react-hooks'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { useToggleLove } from './useToggleLove'
import subsonic from '../subsonic'
-import { useDataProvider } from 'react-admin'
+import { useDataProvider, useNotify } from 'react-admin'
vi.mock('../subsonic', () => ({
default: {
@@ -16,16 +16,21 @@ vi.mock('react-admin', async () => {
return {
...actual,
useDataProvider: vi.fn(),
- useNotify: vi.fn(() => vi.fn()),
+ useNotify: vi.fn(),
}
})
describe('useToggleLove', () => {
let getOne
+ let mockNotify
beforeEach(() => {
+ vi.clearAllMocks()
+ mockNotify = vi.fn()
getOne = vi.fn(() => Promise.resolve())
useDataProvider.mockReturnValue({ getOne })
- vi.clearAllMocks()
+ useNotify.mockReturnValue(mockNotify)
+ subsonic.star.mockResolvedValue()
+ subsonic.unstar.mockResolvedValue()
})
it('uses mediaFileId when present', async () => {
@@ -35,6 +40,8 @@ describe('useToggleLove', () => {
await result.current[0]()
})
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+ expect(getOne).toHaveBeenCalledTimes(2)
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'pt-1' })
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
@@ -57,6 +64,15 @@ describe('useToggleLove', () => {
expect(subsonic.unstar).toHaveBeenCalledWith('sg-1')
})
+ it('does not call notify on a successful toggle', async () => {
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(mockNotify).not.toHaveBeenCalled()
+ })
+
describe('playlist track scenarios', () => {
it('refreshes both playlist track and song for playlist tracks', async () => {
const record = {
@@ -133,4 +149,96 @@ describe('useToggleLove', () => {
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
})
})
+
+ describe('error handling', () => {
+ it('calls notify when star() fails', async () => {
+ subsonic.star.mockRejectedValue(new Error('Network error'))
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
+ })
+
+ it('calls notify when unstar() fails', async () => {
+ subsonic.unstar.mockRejectedValue(new Error('Network error'))
+ const record = { id: 'sg-1', starred: true }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
+ })
+
+ it('does not call notify when refresh fails after a successful toggle', async () => {
+ getOne.mockRejectedValue(new Error('refresh failed'))
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+ expect(mockNotify).not.toHaveBeenCalled()
+ expect(result.current[1]).toBe(false)
+ })
+ })
+
+ describe('loading state', () => {
+ it('is true while the API call is pending and false after resolve', async () => {
+ let resolveToggle
+ subsonic.star.mockReturnValue(new Promise((r) => { resolveToggle = r }))
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+
+ act(() => { result.current[0]() })
+ expect(result.current[1]).toBe(true)
+
+ await act(async () => { resolveToggle() })
+ expect(result.current[1]).toBe(false)
+ })
+
+ it('returns to false even when the API call fails', async () => {
+ subsonic.star.mockRejectedValue(new Error('fail'))
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(result.current[1]).toBe(false)
+ })
+ })
+
+ describe('unmount safety', () => {
+ it('does not warn when the promise resolves after unmount', async () => {
+ let resolveStar
+ subsonic.star.mockReturnValue(
+ new Promise((r) => {
+ resolveStar = r
+ }),
+ )
+ const record = { id: 'sg-1', starred: false }
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const { result, unmount } = renderHook(() =>
+ useToggleLove('song', record),
+ )
+ act(() => {
+ result.current[0]()
+ })
+ unmount()
+ await act(async () => {
+ resolveStar()
+ })
+
+ const stateUpdateWarnings = errorSpy.mock.calls.filter(
+ ([msg]) =>
+ typeof msg === 'string' &&
+ msg.includes("Can't perform a React state update"),
+ )
+ expect(stateUpdateWarnings).toHaveLength(0)
+
+ errorSpy.mockRestore()
+ })
+ })
})
diff --git a/ui/src/common/useToggleLove.test.md b/ui/src/common/useToggleLove.test.md
new file mode 100644
index 000000000..c293a7057
--- /dev/null
+++ b/ui/src/common/useToggleLove.test.md
@@ -0,0 +1,27 @@
+# useToggleLove — Documentación de tests
+
+## Mocks globales
+
+Aplicados en todos los tests vía `beforeEach`:
+
+| Mock | Reemplaza |
+|---|---|
+| `subsonic.star` / `subsonic.unstar` | Llamadas HTTP a la API Subsonic |
+| `useDataProvider` → `{ getOne }` | Acceso al data provider de React Admin |
+| `useNotify` → `mockNotify` | Notificaciones que el hook dispara al usuario |
+
+## Casos de prueba
+
+| Función bajo prueba | Caso de prueba | Mocks utilizados |
+|---|---|---|
+| `toggleLove` (acción de star) | Usa `mediaFileId` para llamar a `star()` cuando el record lo tiene | `subsonic.star`, `dataProvider.getOne` |
+| `toggleLove` (acción de star) | Usa `record.id` como fallback cuando no hay `mediaFileId` | `subsonic.star`, `dataProvider.getOne` |
+| `toggleLove` (acción de unstar) | Llama a `unstar()` cuando el record ya tiene `starred: true` | `subsonic.unstar` |
+| `refreshRecord` (playlist track) | Hace `getOne` tanto al playlist track como a la song cuando hay `mediaFileId` + `playlistId` | `subsonic.star`, `dataProvider.getOne` (×2) |
+| `refreshRecord` (playlist track) | Incluye el filtro `playlist_id` al refrescar el playlist track | `subsonic.unstar`, `dataProvider.getOne` |
+| `refreshRecord` (song directa) | Solo hace un `getOne` al resource original cuando no hay `mediaFileId` | `subsonic.star`, `dataProvider.getOne` (×1) |
+| `refreshRecord` (song directa) | No incluye filtro `playlist_id` para recursos que no son playlist | `subsonic.star`, `dataProvider.getOne` |
+| `toggleLove` (error handling) | Llama a `notify` con mensaje de error cuando `star()` falla | `subsonic.star` (rechazado), `useNotify` |
+| `toggleLove` (error handling) | Llama a `notify` con mensaje de error cuando `unstar()` falla | `subsonic.unstar` (rechazado), `useNotify` |
+| `loading` (estado) | Es `true` mientras la llamada está pendiente y `false` al resolverse | `subsonic.star` (promesa manual con `resolveToggle`) |
+| `loading` (estado) | Vuelve a `false` incluso cuando la llamada falla | `subsonic.star` (rechazado) |
diff --git a/ui/vite.config.js b/ui/vite.config.js
index 9d9c845f1..08f849852 100644
--- a/ui/vite.config.js
+++ b/ui/vite.config.js
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
const frontendPort = parseInt(process.env.PORT) || 4533
+const backendHost = process.env.BACKEND_HOST || 'localhost'
const backendPort = frontendPort + 100
// https://vitejs.dev/config/
@@ -26,7 +27,7 @@ export default defineConfig({
host: true,
port: frontendPort,
proxy: {
- '^/(auth|api|rest|backgrounds)/.*': 'http://localhost:' + backendPort,
+ '^/(auth|api|rest|backgrounds)/.*': `http://${backendHost}:${backendPort}`,
},
},
base: './',