Merge 08c51938680275ad7d265f14d4cbb5a9db13a4bc into 9b862e88337e5f3fe7f8d8d6ed1fdac2c6c5151b

This commit is contained in:
OmarOrjuelaR 2026-06-28 19:40:15 -07:00 committed by GitHub
commit 29dee00126
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3356 additions and 7 deletions

77
CLAUDE.md Normal file
View File

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

7
Dockerfile.dev Normal file
View File

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

View File

@ -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/)

51
docker-compose.dev.yml Normal file
View File

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

238
docs/DIAGRAMA_ER.md Normal file
View File

@ -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/`

251
docs/NAVIDROME_ER.drawio Normal file
View File

@ -0,0 +1,251 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-05-14T10:00:00.000Z" agent="Mozilla/5.0" etag="navidrome_er">
<diagram id="navidrome_er" name="Navidrome ER">
<mxGraphModel dx="1236" dy="756" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" background="white" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- Define estilos -->
<mxCell id="entity_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=11;fontStyle=1" visible="0"/>
<mxCell id="attribute_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10" visible="0"/>
<mxCell id="pk_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1" visible="0"/>
<mxCell id="fk_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;fontStyle=0" visible="0"/>
<mxCell id="relation_style" parent="0" edge="1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6" visible="0"/>
<!-- TABLA: USER -->
<mxCell id="user_entity" parent="1" vertex="1" value="USER" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="50" y="50" width="180" height="120" as="geometry"/>
</mxCell>
<mxCell id="user_pk" parent="1" vertex="1" value="PK user_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="50" y="70" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="user_a1" parent="1" vertex="1" value="user_name (UK)" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="90" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="user_a2" parent="1" vertex="1" value="password" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="110" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="user_a3" parent="1" vertex="1" value="is_admin, created_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="130" width="180" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: ARTIST -->
<mxCell id="artist_entity" parent="1" vertex="1" value="ARTIST" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="350" y="50" width="180" height="90" as="geometry"/>
</mxCell>
<mxCell id="artist_pk" parent="1" vertex="1" value="PK artist_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="350" y="70" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="artist_a1" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="350" y="90" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="artist_a2" parent="1" vertex="1" value="album_count" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="350" y="110" width="180" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: ALBUM -->
<mxCell id="album_entity" parent="1" vertex="1" value="ALBUM" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="650" y="50" width="180" height="120" as="geometry"/>
</mxCell>
<mxCell id="album_pk" parent="1" vertex="1" value="PK album_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="650" y="70" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="album_a1" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="90" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="album_fk" parent="1" vertex="1" value="FK artist_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="110" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="album_a2" parent="1" vertex="1" value="year, duration, genre" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="130" width="180" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: MEDIA_FILE -->
<mxCell id="mediafile_entity" parent="1" vertex="1" value="MEDIA_FILE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="950" y="50" width="200" height="140" as="geometry"/>
</mxCell>
<mxCell id="mediafile_pk" parent="1" vertex="1" value="PK media_file_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="950" y="70" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="mediafile_a1" parent="1" vertex="1" value="path, title" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="90" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="mediafile_fk1" parent="1" vertex="1" value="FK album_id, artist_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="110" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="mediafile_a2" parent="1" vertex="1" value="duration, bit_rate, genre" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="130" width="200" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: PLAYLIST -->
<mxCell id="playlist_entity" parent="1" vertex="1" value="PLAYLIST" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="50" y="250" width="180" height="100" as="geometry"/>
</mxCell>
<mxCell id="playlist_pk" parent="1" vertex="1" value="PK playlist_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="50" y="270" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="playlist_a1" parent="1" vertex="1" value="name, comment" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="290" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="playlist_fk" parent="1" vertex="1" value="FK owner (user_id)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="310" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="playlist_a2" parent="1" vertex="1" value="public, created_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="330" width="180" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: PLAYLIST_TRACKS -->
<mxCell id="playlisttracks_entity" parent="1" vertex="1" value="PLAYLIST_TRACKS" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="350" y="250" width="200" height="80" as="geometry"/>
</mxCell>
<mxCell id="playlisttracks_fk1" parent="1" vertex="1" value="FK playlist_id (PK)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="350" y="270" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="playlisttracks_fk2" parent="1" vertex="1" value="FK media_file_id (PK)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="350" y="290" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="playlisttracks_a1" parent="1" vertex="1" value="track_position" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="350" y="310" width="200" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: ANNOTATION -->
<mxCell id="annotation_entity" parent="1" vertex="1" value="ANNOTATION" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="650" y="250" width="200" height="120" as="geometry"/>
</mxCell>
<mxCell id="annotation_pk" parent="1" vertex="1" value="PK ann_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="650" y="270" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="annotation_fk" parent="1" vertex="1" value="FK user_id, item_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="290" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="annotation_a1" parent="1" vertex="1" value="item_type (polimórfico)" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="310" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="annotation_a2" parent="1" vertex="1" value="play_count, rating, starred" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="330" width="200" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: PLAYQUEUE -->
<mxCell id="playqueue_entity" parent="1" vertex="1" value="PLAYQUEUE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="950" y="250" width="200" height="100" as="geometry"/>
</mxCell>
<mxCell id="playqueue_pk" parent="1" vertex="1" value="PK playqueue_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="950" y="270" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="playqueue_fk1" parent="1" vertex="1" value="FK user_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="290" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="playqueue_fk2" parent="1" vertex="1" value="FK media_file_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="310" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="playqueue_a1" parent="1" vertex="1" value="current_index" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="330" width="200" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: GENRE -->
<mxCell id="genre_entity" parent="1" vertex="1" value="GENRE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="350" y="450" width="150" height="70" as="geometry"/>
</mxCell>
<mxCell id="genre_pk" parent="1" vertex="1" value="PK genre_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="350" y="470" width="150" height="20" as="geometry"/>
</mxCell>
<mxCell id="genre_a1" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="350" y="490" width="150" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: LIBRARY -->
<mxCell id="library_entity" parent="1" vertex="1" value="LIBRARY" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="650" y="450" width="180" height="90" as="geometry"/>
</mxCell>
<mxCell id="library_pk" parent="1" vertex="1" value="PK library_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="650" y="470" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="library_a1" parent="1" vertex="1" value="name, path" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="650" y="490" width="180" height="20" as="geometry"/>
</mxCell>
<!-- TABLA: SHARE -->
<mxCell id="share_entity" parent="1" vertex="1" value="SHARE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
<mxGeometry x="950" y="450" width="200" height="100" as="geometry"/>
</mxCell>
<mxCell id="share_pk" parent="1" vertex="1" value="PK share_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
<mxGeometry x="950" y="470" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="share_fk" parent="1" vertex="1" value="FK created_by (user_id)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="490" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="share_a1" parent="1" vertex="1" value="name, expires, visit_count" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="950" y="510" width="200" height="20" as="geometry"/>
</mxCell>
<!-- RELACIONES -->
<!-- ARTIST -> ALBUM -->
<mxCell id="r1" parent="1" edge="1" source="artist_entity" target="album_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ALBUM -> MEDIA_FILE -->
<mxCell id="r2" parent="1" edge="1" source="album_entity" target="mediafile_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> PLAYLIST -->
<mxCell id="r3" parent="1" edge="1" source="user_entity" target="playlist_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- PLAYLIST -> PLAYLIST_TRACKS -->
<mxCell id="r4" parent="1" edge="1" source="playlist_entity" target="playlisttracks_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE -> PLAYLIST_TRACKS -->
<mxCell id="r5" parent="1" edge="1" source="mediafile_entity" target="playlisttracks_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> ANNOTATION -->
<mxCell id="r6" parent="1" edge="1" source="user_entity" target="annotation_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> PLAYQUEUE -->
<mxCell id="r7" parent="1" edge="1" source="user_entity" target="playqueue_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor:#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE -> PLAYQUEUE -->
<mxCell id="r8" parent="1" edge="1" source="mediafile_entity" target="playqueue_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> SHARE -->
<mxCell id="r9" parent="1" edge="1" source="user_entity" target="share_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- LEYENDA -->
<mxCell id="legend_title" parent="1" vertex="1" value="LEYENDA" style="shape=rectangle;filled=0;strokeColor=none;rounded=0;fontSize=12;fontStyle=1;align=left">
<mxGeometry x="50" y="600" width="100" height="30" as="geometry"/>
</mxCell>
<mxCell id="legend_pk" parent="1" vertex="1" value="PK = Primary Key" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="630" width="150" height="20" as="geometry"/>
</mxCell>
<mxCell id="legend_fk" parent="1" vertex="1" value="FK = Foreign Key" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="50" y="660" width="150" height="20" as="geometry"/>
</mxCell>
<mxCell id="legend_attr" parent="1" vertex="1" value="Atributo regular" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
<mxGeometry x="250" y="630" width="150" height="20" as="geometry"/>
</mxCell>
<mxCell id="legend_rel" parent="1" vertex="1" value="1:N = One-to-Many" style="shape=rectangle;filled=0;strokeColor=#9673a6;rounded=0;fontSize=10;align=left">
<mxGeometry x="250" y="660" width="150" height="20" as="geometry"/>
</mxCell>
<mxCell id="note" parent="1" vertex="1" value="Navidrome - Diagrama Entidad Relación&#xa;SQLite con Foreign Keys habilitadas&#xa;Actualizado: 2026-05-14" style="shape=rectangle;filled=1;fillColor=#e8f4f8;strokeColor=#333333;rounded=0;fontSize=9;align=left;spacingLeft=5;dashed=1">
<mxGeometry x="50" y="690" width="1100" height="60" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@ -0,0 +1,529 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-05-14T10:00:00.000Z" agent="Mozilla/5.0" version="24.0.0" type="device">
<diagram id="navidrome_complete_er" name="Navidrome Complete ER">
<mxGraphModel dx="2000" dy="1400" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2000" pageHeight="1400" background="white" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- USER table -->
<mxCell id="user_title" parent="1" vertex="1" value="USER" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="20" y="20" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="user_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="45" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="user_uk_user_name" parent="1" vertex="1" value="UK user_name" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="60" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="user_uk_email" parent="1" vertex="1" value="UK email" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="75" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="user_cols" parent="1" vertex="1" value="name, password, is_admin&#xa;last_login_at, last_access_at&#xa;created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="20" y="90" width="180" height="40" as="geometry"/>
</mxCell>
<!-- ARTIST table -->
<mxCell id="artist_title" parent="1" vertex="1" value="ARTIST" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="250" y="20" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="artist_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="250" y="45" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="artist_cols" parent="1" vertex="1" value="name, album_count, artist_count&#xa;folder_count, full_text" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="250" y="60" width="180" height="30" as="geometry"/>
</mxCell>
<!-- LIBRARY table -->
<mxCell id="library_title" parent="1" vertex="1" value="LIBRARY" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="480" y="20" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="library_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="480" y="45" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="library_cols" parent="1" vertex="1" value="name, path&#xa;last_scan_started_at&#xa;full_scan_in_progress" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="480" y="60" width="180" height="40" as="geometry"/>
</mxCell>
<!-- ALBUM table -->
<mxCell id="album_title" parent="1" vertex="1" value="ALBUM" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="710" y="20" width="200" height="25" as="geometry"/>
</mxCell>
<mxCell id="album_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="710" y="45" width="200" height="15" as="geometry"/>
</mxCell>
<mxCell id="album_fk_artist" parent="1" vertex="1" value="FK artist_id → ARTIST" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="710" y="60" width="200" height="15" as="geometry"/>
</mxCell>
<mxCell id="album_cols" parent="1" vertex="1" value="name, artist, album_artist, year&#xa;compilation, song_count, duration, genre&#xa;cover_art_path, mbid&#xa;created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="710" y="75" width="200" height="50" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE table -->
<mxCell id="mediafile_title" parent="1" vertex="1" value="MEDIA_FILE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="960" y="20" width="220" height="25" as="geometry"/>
</mxCell>
<mxCell id="mediafile_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="960" y="45" width="220" height="15" as="geometry"/>
</mxCell>
<mxCell id="mediafile_fk_album" parent="1" vertex="1" value="FK album_id → ALBUM" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="960" y="60" width="220" height="15" as="geometry"/>
</mxCell>
<mxCell id="mediafile_fk_artist" parent="1" vertex="1" value="FK artist_id → ARTIST | FK library_id → LIBRARY" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="960" y="75" width="220" height="15" as="geometry"/>
</mxCell>
<mxCell id="mediafile_fk_folder" parent="1" vertex="1" value="FK folder_id → FOLDER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="960" y="90" width="220" height="15" as="geometry"/>
</mxCell>
<mxCell id="mediafile_cols" parent="1" vertex="1" value="path, title, track_number, disc_number&#xa;year, size, suffix, duration, bit_rate&#xa;genre, mbid, created_at, updated_at" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="960" y="105" width="220" height="45" as="geometry"/>
</mxCell>
<!-- PLAYLIST table -->
<mxCell id="playlist_title" parent="1" vertex="1" value="PLAYLIST" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="20" y="200" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="playlist_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="225" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="playlist_fk_owner" parent="1" vertex="1" value="FK owner → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="240" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="playlist_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="255" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="playlist_cols" parent="1" vertex="1" value="name, comment, duration&#xa;public, playlist_type, rules&#xa;image_file, created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="20" y="270" width="180" height="45" as="geometry"/>
</mxCell>
<!-- PLAYLIST_TRACKS table -->
<mxCell id="playlisttracks_title" parent="1" vertex="1" value="PLAYLIST_TRACKS" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="250" y="200" width="210" height="25" as="geometry"/>
</mxCell>
<mxCell id="playlisttracks_pk_id" parent="1" vertex="1" value="PK id (composite with playlist_id)" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="250" y="225" width="210" height="15" as="geometry"/>
</mxCell>
<mxCell id="playlisttracks_fk_playlist" parent="1" vertex="1" value="FK playlist_id → PLAYLIST" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="250" y="240" width="210" height="15" as="geometry"/>
</mxCell>
<mxCell id="playlisttracks_fk_mediafile" parent="1" vertex="1" value="FK media_file_id → MEDIA_FILE" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="250" y="255" width="210" height="15" as="geometry"/>
</mxCell>
<!-- ANNOTATION table -->
<mxCell id="annotation_title" parent="1" vertex="1" value="ANNOTATION (Polimórfico)" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="510" y="200" width="210" height="25" as="geometry"/>
</mxCell>
<mxCell id="annotation_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="510" y="225" width="210" height="15" as="geometry"/>
</mxCell>
<mxCell id="annotation_cols" parent="1" vertex="1" value="item_id, item_type (album|track|artist|etc)&#xa;play_count, play_date, rating&#xa;starred, starred_at, rating_date" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="510" y="240" width="210" height="45" as="geometry"/>
</mxCell>
<!-- PLAYER table -->
<mxCell id="player_title" parent="1" vertex="1" value="PLAYER" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="760" y="200" width="190" height="25" as="geometry"/>
</mxCell>
<mxCell id="player_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="760" y="225" width="190" height="15" as="geometry"/>
</mxCell>
<mxCell id="player_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="760" y="240" width="190" height="15" as="geometry"/>
</mxCell>
<mxCell id="player_fk_transoding" parent="1" vertex="1" value="FK transcoding_id → TRANSCODING" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="760" y="255" width="190" height="15" as="geometry"/>
</mxCell>
<mxCell id="player_cols" parent="1" vertex="1" value="name, type, client, ip_address&#xa;last_seen, max_bit_rate" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="760" y="270" width="190" height="35" as="geometry"/>
</mxCell>
<!-- PLAYQUEUE table -->
<mxCell id="playqueue_title" parent="1" vertex="1" value="PLAYQUEUE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="1010" y="200" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="playqueue_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="1010" y="225" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="playqueue_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="1010" y="240" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="playqueue_cols" parent="1" vertex="1" value="comment, current, position&#xa;changed_by, items, current_index&#xa;created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="1010" y="255" width="180" height="45" as="geometry"/>
</mxCell>
<!-- FOLDER table -->
<mxCell id="folder_title" parent="1" vertex="1" value="FOLDER" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="20" y="380" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="folder_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="405" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="folder_fk_library" parent="1" vertex="1" value="FK library_id → LIBRARY" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="420" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="folder_cols" parent="1" vertex="1" value="path, name, missing&#xa;parent_id, num_audio_files&#xa;num_playlists, image_files&#xa;created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="20" y="435" width="180" height="50" as="geometry"/>
</mxCell>
<!-- BOOKMARK table -->
<mxCell id="bookmark_title" parent="1" vertex="1" value="BOOKMARK" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="250" y="380" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="bookmark_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="250" y="405" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="bookmark_cols" parent="1" vertex="1" value="item_id, item_type&#xa;comment, position, changed_by&#xa;created_at, updated_at" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="250" y="420" width="180" height="45" as="geometry"/>
</mxCell>
<!-- TRANSCODING table -->
<mxCell id="transcoding_title" parent="1" vertex="1" value="TRANSCODING" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="480" y="380" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="transcoding_pk_id" parent="1" vertex="1" value="PK id (UK: target_format)" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="480" y="405" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="transcoding_cols" parent="1" vertex="1" value="name (UK), target_format (UK)&#xa;command, default_bit_rate" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="480" y="420" width="180" height="35" as="geometry"/>
</mxCell>
<!-- SHARE table -->
<mxCell id="share_title" parent="1" vertex="1" value="SHARE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="710" y="380" width="200" height="25" as="geometry"/>
</mxCell>
<mxCell id="share_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="710" y="405" width="200" height="15" as="geometry"/>
</mxCell>
<mxCell id="share_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="710" y="420" width="200" height="15" as="geometry"/>
</mxCell>
<mxCell id="share_cols" parent="1" vertex="1" value="name (UK), description, expires&#xa;created, last_visited, resource_ids&#xa;resource_type, visit_count&#xa;download, comments" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="710" y="435" width="200" height="50" as="geometry"/>
</mxCell>
<!-- SCROBBLE_BUFFER table -->
<mxCell id="scrobble_title" parent="1" vertex="1" value="SCROBBLE_BUFFER" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="960" y="380" width="220" height="25" as="geometry"/>
</mxCell>
<mxCell id="scrobble_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="960" y="405" width="220" height="15" as="geometry"/>
</mxCell>
<mxCell id="scrobble_fk" parent="1" vertex="1" value="FK user_id → USER | FK media_file_id → MEDIA_FILE" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="960" y="420" width="220" height="15" as="geometry"/>
</mxCell>
<mxCell id="scrobble_cols" parent="1" vertex="1" value="artist_name, album_name, track_name&#xa;play_time" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="960" y="435" width="220" height="30" as="geometry"/>
</mxCell>
<!-- USER_PROPS table -->
<mxCell id="userprops_title" parent="1" vertex="1" value="USER_PROPS" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="20" y="550" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="userprops_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="20" y="575" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="userprops_cols" parent="1" vertex="1" value="property_name, property_value" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="20" y="590" width="180" height="15" as="geometry"/>
</mxCell>
<!-- TAG table -->
<mxCell id="tag_title" parent="1" vertex="1" value="TAG" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="250" y="550" width="150" height="25" as="geometry"/>
</mxCell>
<mxCell id="tag_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="250" y="575" width="150" height="15" as="geometry"/>
</mxCell>
<mxCell id="tag_cols" parent="1" vertex="1" value="tag_name, tag_value (UK)&#xa;album_count, media_file_count" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="250" y="590" width="150" height="30" as="geometry"/>
</mxCell>
<!-- GENRE table -->
<mxCell id="genre_title" parent="1" vertex="1" value="GENRE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="460" y="550" width="140" height="25" as="geometry"/>
</mxCell>
<mxCell id="genre_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="460" y="575" width="140" height="15" as="geometry"/>
</mxCell>
<mxCell id="genre_cols" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="460" y="590" width="140" height="15" as="geometry"/>
</mxCell>
<!-- RADIO table -->
<mxCell id="radio_title" parent="1" vertex="1" value="RADIO" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
<mxGeometry x="660" y="550" width="180" height="25" as="geometry"/>
</mxCell>
<mxCell id="radio_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="660" y="575" width="180" height="15" as="geometry"/>
</mxCell>
<mxCell id="radio_cols" parent="1" vertex="1" value="name, stream_url, homepage_url" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="660" y="590" width="180" height="25" as="geometry"/>
</mxCell>
<!-- MANY-TO-MANY JUNCTION TABLES -->
<!-- ALBUM_GENRES -->
<mxCell id="albumgenres_title" parent="1" vertex="1" value="ALBUM_GENRES" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="710" y="550" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="albumgenres_fk" parent="1" vertex="1" value="FK album_id, genre_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="710" y="570" width="180" height="15" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE_GENRES -->
<mxCell id="mediafilegenres_title" parent="1" vertex="1" value="MEDIA_FILE_GENRES" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="910" y="550" width="210" height="20" as="geometry"/>
</mxCell>
<mxCell id="mediafilegenres_fk" parent="1" vertex="1" value="FK media_file_id, genre_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="910" y="570" width="210" height="15" as="geometry"/>
</mxCell>
<!-- ARTIST_GENRES -->
<mxCell id="artistgenres_title" parent="1" vertex="1" value="ARTIST_GENRES" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="1160" y="550" width="190" height="20" as="geometry"/>
</mxCell>
<mxCell id="artistgenres_fk" parent="1" vertex="1" value="FK artist_id, genre_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="1160" y="570" width="190" height="15" as="geometry"/>
</mxCell>
<!-- ALBUM_ARTISTS -->
<mxCell id="albumartists_title" parent="1" vertex="1" value="ALBUM_ARTISTS" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="20" y="680" width="190" height="20" as="geometry"/>
</mxCell>
<mxCell id="albumartists_fk" parent="1" vertex="1" value="FK album_id, artist_id, role, sub_role" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="20" y="700" width="190" height="20" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE_ARTISTS -->
<mxCell id="mediafileartists_title" parent="1" vertex="1" value="MEDIA_FILE_ARTISTS" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="240" y="680" width="220" height="20" as="geometry"/>
</mxCell>
<mxCell id="mediafileartists_fk" parent="1" vertex="1" value="FK media_file_id, artist_id, role, sub_role" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="240" y="700" width="220" height="20" as="geometry"/>
</mxCell>
<!-- LIBRARY_ARTIST -->
<mxCell id="libraryartist_title" parent="1" vertex="1" value="LIBRARY_ARTIST" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="510" y="680" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="libraryartist_fk" parent="1" vertex="1" value="FK library_id, artist_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="510" y="700" width="200" height="15" as="geometry"/>
</mxCell>
<!-- LIBRARY_TAG -->
<mxCell id="librarytag_title" parent="1" vertex="1" value="LIBRARY_TAG" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="760" y="680" width="190" height="20" as="geometry"/>
</mxCell>
<mxCell id="librarytag_fk" parent="1" vertex="1" value="FK library_id, tag_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="760" y="700" width="190" height="15" as="geometry"/>
</mxCell>
<!-- USER_LIBRARY -->
<mxCell id="userlibrary_title" parent="1" vertex="1" value="USER_LIBRARY" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
<mxGeometry x="1010" y="680" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="userlibrary_fk" parent="1" vertex="1" value="FK user_id, library_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
<mxGeometry x="1010" y="700" width="180" height="15" as="geometry"/>
</mxCell>
<!-- LEGEND -->
<mxCell id="legend_box" parent="1" vertex="1" value="" style="shape=rectangle;filled=0;strokeColor=#999999;rounded=0;fontSize=10;dashed=1">
<mxGeometry x="20" y="770" width="1170" height="80" as="geometry"/>
</mxCell>
<mxCell id="legend_title" parent="1" vertex="1" value="LEYENDA" style="shape=rectangle;filled=0;strokeColor=none;rounded=0;fontSize=11;fontStyle=1;align=left">
<mxGeometry x="30" y="775" width="80" height="15" as="geometry"/>
</mxCell>
<mxCell id="legend_pk" parent="1" vertex="1" value="PK = Primary Key (amarillo)" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="30" y="795" width="200" height="18" as="geometry"/>
</mxCell>
<mxCell id="legend_fk" parent="1" vertex="1" value="FK = Foreign Key (azul)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="260" y="795" width="200" height="18" as="geometry"/>
</mxCell>
<mxCell id="legend_uk" parent="1" vertex="1" value="UK = Unique Key (amarillo turbio)" style="shape=rectangle;filled=0;strokeColor=#999999;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="490" y="795" width="220" height="18" as="geometry"/>
</mxCell>
<mxCell id="legend_entity" parent="1" vertex="1" value="Entidad (púrpura/gris)" style="shape=rectangle;filled=1;fillColor=#f0e6ff;strokeColor=#9673a6;rounded=0;fontSize=9;align=left;spacingLeft=5">
<mxGeometry x="760" y="795" width="180" height="18" as="geometry"/>
</mxCell>
<mxCell id="legend_note" parent="1" vertex="1" value="• Foreign Keys con ON DELETE CASCADE (integridad referencial)&#xa;• ANNOTATION es polimórfico (item_id + item_type soportan múltiples entidades)&#xa;• SQLite con FTS5 para búsquedas full-text. Migraciones con Goose.&#xa;• 30+ tablas representadas. Uniones M:N con color más claro." style="shape=rectangle;filled=0;strokeColor=none;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
<mxGeometry x="30" y="825" width="1160" height="60" as="geometry"/>
</mxCell>
<!-- RELACIONES 1:N -->
<!-- USER -> PLAYLIST (owner) -->
<mxCell id="rel_user_playlist_owner" parent="1" edge="1" source="user_title" target="playlist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> PLAYLIST (user_id) -->
<mxCell id="rel_user_playlist_userid" parent="1" edge="1" source="user_title" target="playlist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ARTIST -> ALBUM -->
<mxCell id="rel_artist_album" parent="1" edge="1" source="artist_title" target="album_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ARTIST -> MEDIA_FILE -->
<mxCell id="rel_artist_mediafile" parent="1" edge="1" source="artist_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ALBUM -> MEDIA_FILE -->
<mxCell id="rel_album_mediafile" parent="1" edge="1" source="album_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- LIBRARY -> MEDIA_FILE -->
<mxCell id="rel_library_mediafile" parent="1" edge="1" source="library_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- LIBRARY -> FOLDER -->
<mxCell id="rel_library_folder" parent="1" edge="1" source="library_title" target="folder_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- FOLDER -> MEDIA_FILE -->
<mxCell id="rel_folder_mediafile" parent="1" edge="1" source="folder_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- PLAYLIST -> PLAYLIST_TRACKS -->
<mxCell id="rel_playlist_tracks" parent="1" edge="1" source="playlist_title" target="playlisttracks_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE -> PLAYLIST_TRACKS -->
<mxCell id="rel_mediafile_tracks" parent="1" edge="1" source="mediafile_title" target="playlisttracks_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> ANNOTATION -->
<mxCell id="rel_user_annotation" parent="1" edge="1" source="user_title" target="annotation_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> PLAYER -->
<mxCell id="rel_user_player" parent="1" edge="1" source="user_title" target="player_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- TRANSCODING -> PLAYER -->
<mxCell id="rel_transcoding_player" parent="1" edge="1" source="transcoding_title" target="player_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> PLAYQUEUE -->
<mxCell id="rel_user_playqueue" parent="1" edge="1" source="user_title" target="playqueue_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> BOOKMARK -->
<mxCell id="rel_user_bookmark" parent="1" edge="1" source="user_title" target="bookmark_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> SHARE -->
<mxCell id="rel_user_share" parent="1" edge="1" source="user_title" target="share_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> SCROBBLE_BUFFER -->
<mxCell id="rel_user_scrobble" parent="1" edge="1" source="user_title" target="scrobble_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> USER_PROPS -->
<mxCell id="rel_user_userprops" parent="1" edge="1" source="user_title" target="userprops_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE -> SCROBBLE_BUFFER -->
<mxCell id="rel_mediafile_scrobble" parent="1" edge="1" source="mediafile_title" target="scrobble_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- GENRE -> ALBUM_GENRES -->
<mxCell id="rel_genre_albumgenres" parent="1" edge="1" source="genre_title" target="albumgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ALBUM -> ALBUM_GENRES -->
<mxCell id="rel_album_albumgenres" parent="1" edge="1" source="album_title" target="albumgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- GENRE -> MEDIA_FILE_GENRES -->
<mxCell id="rel_genre_mediafilegenres" parent="1" edge="1" source="genre_title" target="mediafilegenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE -> MEDIA_FILE_GENRES -->
<mxCell id="rel_mediafile_mediafilegenres" parent="1" edge="1" source="mediafile_title" target="mediafilegenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- GENRE -> ARTIST_GENRES -->
<mxCell id="rel_genre_artistgenres" parent="1" edge="1" source="genre_title" target="artistgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ARTIST -> ARTIST_GENRES -->
<mxCell id="rel_artist_artistgenres" parent="1" edge="1" source="artist_title" target="artistgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ALBUM -> ALBUM_ARTISTS -->
<mxCell id="rel_album_albumartists" parent="1" edge="1" source="album_title" target="albumartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ARTIST -> ALBUM_ARTISTS -->
<mxCell id="rel_artist_albumartists" parent="1" edge="1" source="artist_title" target="albumartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- MEDIA_FILE -> MEDIA_FILE_ARTISTS -->
<mxCell id="rel_mediafile_mediafileartists" parent="1" edge="1" source="mediafile_title" target="mediafileartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ARTIST -> MEDIA_FILE_ARTISTS -->
<mxCell id="rel_artist_mediafileartists" parent="1" edge="1" source="artist_title" target="mediafileartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- LIBRARY -> LIBRARY_ARTIST -->
<mxCell id="rel_library_libraryartist" parent="1" edge="1" source="library_title" target="libraryartist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- ARTIST -> LIBRARY_ARTIST -->
<mxCell id="rel_artist_libraryartist" parent="1" edge="1" source="artist_title" target="libraryartist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- LIBRARY -> LIBRARY_TAG -->
<mxCell id="rel_library_librarytag" parent="1" edge="1" source="library_title" target="librarytag_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- TAG -> LIBRARY_TAG -->
<mxCell id="rel_tag_librarytag" parent="1" edge="1" source="tag_title" target="librarytag_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- USER -> USER_LIBRARY -->
<mxCell id="rel_user_userlibrary" parent="1" edge="1" source="user_title" target="userlibrary_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<!-- LIBRARY -> USER_LIBRARY -->
<mxCell id="rel_library_userlibrary" parent="1" edge="1" source="library_title" target="userlibrary_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

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

View File

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

View File

@ -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())
})
})
})

View File

@ -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))
})
})
})

View File

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

View File

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

66
testing-flows.md Normal file
View File

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

View File

@ -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())
})
})
})

View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

@ -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 ? <button data-testid="love-button" /> : 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(<AlbumContextMenu record={albumRecord} />)
expect(screen.getByTestId('love-button')).toBeInTheDocument()
})
it('does not render LoveButton when enableFavourites is false', () => {
mockConfig.enableFavourites = false
render(<AlbumContextMenu record={albumRecord} />)
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
})
it('does not render LoveButton when showLove is false', () => {
render(<AlbumContextMenu record={albumRecord} showLove={false} />)
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
})
it('passes resource="album" to LoveButton', () => {
render(<AlbumContextMenu record={albumRecord} />)
expect(capturedLoveButtonProps.resource).toBe('album')
})
it('hides LoveButton when record.missing is true', () => {
render(<AlbumContextMenu record={{ ...albumRecord, missing: true }} />)
expect(capturedLoveButtonProps.visible).toBe(false)
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
})
it('renders nothing when record is undefined', () => {
const { container } = render(<AlbumContextMenu />)
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(<ArtistContextMenu record={artistRecord} />)
expect(screen.getByTestId('love-button')).toBeInTheDocument()
})
it('does not render LoveButton when enableFavourites is false', () => {
mockConfig.enableFavourites = false
render(<ArtistContextMenu record={artistRecord} />)
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
})
it('does not render LoveButton when showLove is false', () => {
render(<ArtistContextMenu record={artistRecord} showLove={false} />)
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
})
it('passes resource="artist" to LoveButton', () => {
render(<ArtistContextMenu record={artistRecord} />)
expect(capturedLoveButtonProps.resource).toBe('artist')
})
it('hides LoveButton when record.missing is true', () => {
render(<ArtistContextMenu record={{ ...artistRecord, missing: true }} />)
expect(capturedLoveButtonProps.visible).toBe(false)
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
})
it('renders nothing when record is undefined', () => {
const { container } = render(<ArtistContextMenu />)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -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: () => <span data-testid="favorite-icon" />,
}))
vi.mock('@material-ui/icons/FavoriteBorder', () => ({
default: () => <span data-testid="favorite-border-icon" />,
}))
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(<LoveButton resource="song" />)
expect(container).toBeEmptyDOMElement()
})
it('renders a button when enableFavourites is true', () => {
render(<LoveButton resource="song" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('is disabled when loading is true', () => {
useToggleLove.mockReturnValue([mockToggleLove, true])
render(<LoveButton resource="song" />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('is disabled when record.missing is true', () => {
useRecordContext.mockReturnValue({ id: 'song-1', starred: false, missing: true })
render(<LoveButton resource="song" />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('is disabled when disabled prop is true', () => {
render(<LoveButton resource="song" disabled={true} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('calls toggleLove when clicked', () => {
render(<LoveButton resource="song" />)
fireEvent.click(screen.getByRole('button'))
expect(mockToggleLove).toHaveBeenCalledTimes(1)
})
it('stops click propagation to parent elements', () => {
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<LoveButton resource="song" />
</div>,
)
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(<LoveButton resource="song" />)
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(<LoveButton resource="song" />)
expect(screen.getByRole('button')).not.toHaveAttribute('title')
})
it('renders FavoriteIcon when record is starred', () => {
useRecordContext.mockReturnValue({ id: 'song-1', starred: true })
render(<LoveButton resource="song" />)
expect(screen.getByTestId('favorite-icon')).toBeInTheDocument()
expect(screen.queryByTestId('favorite-border-icon')).not.toBeInTheDocument()
})
it('renders FavoriteBorderIcon when record is not starred', () => {
render(<LoveButton resource="song" />)
expect(screen.getByTestId('favorite-border-icon')).toBeInTheDocument()
expect(screen.queryByTestId('favorite-icon')).not.toBeInTheDocument()
})
})

View File

@ -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()
})
})
})

View File

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

View File

@ -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: './',