161 lines
5.8 KiB
Go
161 lines
5.8 KiB
Go
package scanner
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestUpdateIdentityPreservesProbeData covers the scanner's metadata-only update
|
||
|
|
// path (issue #319 hardening): rewriting a file's derived identity/grouping must
|
||
|
|
// persist the new root/group columns while leaving probe data, file bytes, and
|
||
|
|
// content linkage untouched — no ffprobe, no probe-column churn. Like every
|
||
|
|
// scan write it must clear match suppression, and it must follow folder moves.
|
||
|
|
func TestUpdateIdentityPreservesProbeData(t *testing.T) {
|
||
|
|
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
||
|
|
if dsn == "" {
|
||
|
|
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
pool, err := pgxpool.New(ctx, dsn)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("connect test database: %v", err)
|
||
|
|
}
|
||
|
|
t.Cleanup(pool.Close)
|
||
|
|
|
||
|
|
suffix := time.Now().UnixNano()
|
||
|
|
contentID := fmt.Sprintf("ui-content-%d", suffix)
|
||
|
|
path := fmt.Sprintf("/tmp/ui-%d/Movie (2020) {tvdb-1}/Movie (2020).mkv", suffix)
|
||
|
|
probedAt := time.Now().Add(-72 * time.Hour).UTC().Truncate(time.Second)
|
||
|
|
|
||
|
|
var folderID, movedFolderID int
|
||
|
|
if err := pool.QueryRow(ctx, `
|
||
|
|
INSERT INTO media_folders (type, name, enabled) VALUES ('movies', 'UI Test', true) RETURNING id
|
||
|
|
`).Scan(&folderID); err != nil {
|
||
|
|
t.Fatalf("seed folder: %v", err)
|
||
|
|
}
|
||
|
|
if err := pool.QueryRow(ctx, `
|
||
|
|
INSERT INTO media_folders (type, name, enabled) VALUES ('movies', 'UI Test Moved', true) RETURNING id
|
||
|
|
`).Scan(&movedFolderID); err != nil {
|
||
|
|
t.Fatalf("seed moved folder: %v", err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() {
|
||
|
|
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = ANY($1)`, []int{folderID, movedFolderID})
|
||
|
|
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = ANY($1)`, []int{folderID, movedFolderID})
|
||
|
|
})
|
||
|
|
|
||
|
|
var fileID int
|
||
|
|
if err := pool.QueryRow(ctx, `
|
||
|
|
INSERT INTO media_files (
|
||
|
|
content_id, media_folder_id, file_path, file_size,
|
||
|
|
observed_root_path, canonical_root_path, content_group_key, group_key_version,
|
||
|
|
base_title, base_year, base_type,
|
||
|
|
codec_video, codec_audio, resolution, container, duration, bitrate,
|
||
|
|
video_tracks, audio_tracks, chapters, probe_source, probe_updated_at,
|
||
|
|
match_suppressed_at
|
||
|
|
) VALUES (
|
||
|
|
$1, $2, $3, 123456,
|
||
|
|
'/old/root', '/old/root', 'v1|movie|movie|2020', 1,
|
||
|
|
'Movie', 2020, 'movie',
|
||
|
|
'h264', 'aac', '1080p', 'mkv', 7200, 5000,
|
||
|
|
'[{"index":0}]'::jsonb, '[{"index":1}]'::jsonb, '[]'::jsonb, 'local', $4,
|
||
|
|
NOW()
|
||
|
|
) RETURNING id
|
||
|
|
`, contentID, folderID, path, probedAt).Scan(&fileID); err != nil {
|
||
|
|
t.Fatalf("seed media file: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
repo := NewFileRepository(pool)
|
||
|
|
updatedID, err := repo.UpdateIdentity(ctx, models.MediaFile{
|
||
|
|
MediaFolderID: movedFolderID,
|
||
|
|
FilePath: path,
|
||
|
|
ObservedRootPath: "/new/root",
|
||
|
|
CanonicalRootPath: "/new/root",
|
||
|
|
ContentGroupKey: "v1|movie|anchor|tvdb-1",
|
||
|
|
GroupKeyVersion: 1,
|
||
|
|
BaseTitle: "Movie",
|
||
|
|
BaseYear: 2020,
|
||
|
|
BaseType: "movie",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("UpdateIdentity: %v", err)
|
||
|
|
}
|
||
|
|
if updatedID != fileID {
|
||
|
|
t.Errorf("UpdateIdentity id = %d, want %d", updatedID, fileID)
|
||
|
|
}
|
||
|
|
|
||
|
|
updated, err := repo.GetByPath(ctx, path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("GetByPath after UpdateIdentity: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Identity/grouping columns rewritten.
|
||
|
|
if updated.ContentGroupKey != "v1|movie|anchor|tvdb-1" {
|
||
|
|
t.Errorf("content_group_key = %q, want anchored form", updated.ContentGroupKey)
|
||
|
|
}
|
||
|
|
if updated.ObservedRootPath != "/new/root" {
|
||
|
|
t.Errorf("observed_root_path = %q, want /new/root", updated.ObservedRootPath)
|
||
|
|
}
|
||
|
|
if updated.CanonicalRootPath != "/new/root" {
|
||
|
|
t.Errorf("canonical_root_path = %q, want /new/root", updated.CanonicalRootPath)
|
||
|
|
}
|
||
|
|
if updated.BaseTitle != "Movie" || updated.BaseYear != 2020 || updated.BaseType != "movie" {
|
||
|
|
t.Errorf("base title/year/type = %q/%d/%q, want Movie/2020/movie",
|
||
|
|
updated.BaseTitle, updated.BaseYear, updated.BaseType)
|
||
|
|
}
|
||
|
|
if updated.MediaFolderID != movedFolderID {
|
||
|
|
t.Errorf("media_folder_id = %d, want moved folder %d", updated.MediaFolderID, movedFolderID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Probe data and linkage preserved.
|
||
|
|
if updated.ContentID != contentID {
|
||
|
|
t.Errorf("content_id = %q, want preserved %q", updated.ContentID, contentID)
|
||
|
|
}
|
||
|
|
if updated.CodecVideo != "h264" || updated.CodecAudio != "aac" || updated.Resolution != "1080p" {
|
||
|
|
t.Errorf("probe codecs mutated: video=%q audio=%q res=%q", updated.CodecVideo, updated.CodecAudio, updated.Resolution)
|
||
|
|
}
|
||
|
|
if updated.Duration != 7200 {
|
||
|
|
t.Errorf("duration = %d, want preserved 7200", updated.Duration)
|
||
|
|
}
|
||
|
|
if updated.ProbeSource != "local" {
|
||
|
|
t.Errorf("probe_source = %q, want preserved local", updated.ProbeSource)
|
||
|
|
}
|
||
|
|
if updated.ProbeUpdatedAt == nil || !updated.ProbeUpdatedAt.Equal(probedAt) {
|
||
|
|
t.Errorf("probe_updated_at = %v, want preserved %v", updated.ProbeUpdatedAt, probedAt)
|
||
|
|
}
|
||
|
|
if len(updated.VideoTracks) != 1 || len(updated.AudioTracks) != 1 {
|
||
|
|
t.Errorf("track arrays mutated: video=%d audio=%d", len(updated.VideoTracks), len(updated.AudioTracks))
|
||
|
|
}
|
||
|
|
if updated.FileSize != 123456 {
|
||
|
|
t.Errorf("file_size = %d, want preserved 123456", updated.FileSize)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Match suppression cleared like any other scan write, so the fresh
|
||
|
|
// identity re-enters the match backlog.
|
||
|
|
var suppressed bool
|
||
|
|
if err := pool.QueryRow(ctx, `
|
||
|
|
SELECT match_suppressed_at IS NOT NULL FROM media_files WHERE id = $1
|
||
|
|
`, fileID).Scan(&suppressed); err != nil {
|
||
|
|
t.Fatalf("read match_suppressed_at: %v", err)
|
||
|
|
}
|
||
|
|
if suppressed {
|
||
|
|
t.Error("match_suppressed_at still set, want cleared by identity update")
|
||
|
|
}
|
||
|
|
|
||
|
|
// A vanished row surfaces as ErrFileNotFound so the scanner can fall back
|
||
|
|
// to the full upsert path.
|
||
|
|
if _, err := repo.UpdateIdentity(ctx, models.MediaFile{
|
||
|
|
MediaFolderID: folderID,
|
||
|
|
FilePath: path + ".does-not-exist",
|
||
|
|
}); !errors.Is(err, ErrFileNotFound) {
|
||
|
|
t.Errorf("UpdateIdentity on missing row: err = %v, want ErrFileNotFound", err)
|
||
|
|
}
|
||
|
|
}
|