stash/pkg/image/scan_test.go
Abdu Dihan 52bd9392fb
Fix stale browser-cached thumbnails after file content changes during scan. (#6622)
* Fix stale thumbnails after file content changes

When a file's content changed (e.g. after renaming files in a gallery),
the scan handler updated fingerprints but did not bump the entity's
updated_at timestamp. Since thumbnail URLs use updated_at as a cache
buster and are served with immutable/1-year cache headers, browsers
would indefinitely serve the old cached thumbnail.

Update image, scene, and gallery scan handlers to call UpdatePartial
(which sets updated_at to now) whenever file content changes, not only
when a new file association is created.
2026-03-02 15:53:02 +11:00

120 lines
3.5 KiB
Go

package image
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type mockScanConfig struct{}
func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false }
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
const (
testImageID = 1
testFileID = 100
)
existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"}
makeImage := func() *models.Image {
return &models.Image{
ID: testImageID,
Files: models.NewRelatedFiles([]models.File{existingFile}),
GalleryIDs: models.NewRelatedIDs([]int{}),
}
}
tests := []struct {
name string
updateExisting bool
expectUpdate bool
}{
{
name: "calls UpdatePartial when file content changed",
updateExisting: true,
expectUpdate: true,
},
{
name: "skips UpdatePartial when file unchanged and already associated",
updateExisting: false,
expectUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := mocks.NewDatabase()
db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)
db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil)
if tt.expectUpdate {
db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything).
Return(&models.Image{ID: testImageID}, nil)
}
h := &ScanHandler{
CreatorUpdater: db.Image,
GalleryFinder: db.Gallery,
ScanConfig: &mockScanConfig{},
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting)
assert.NoError(t, err)
})
if tt.expectUpdate {
db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything)
} else {
db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
}
})
}
}
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
const (
testImageID = 1
existFileID = 100
newFileID = 200
)
existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"}
newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"}
image := &models.Image{
ID: testImageID,
Files: models.NewRelatedFiles([]models.File{existingFile}),
GalleryIDs: models.NewRelatedIDs([]int{}),
}
db := mocks.NewDatabase()
db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)
db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil)
db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil)
db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything).
Return(&models.Image{ID: testImageID}, nil)
h := &ScanHandler{
CreatorUpdater: db.Image,
GalleryFinder: db.Gallery,
ScanConfig: &mockScanConfig{},
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Image{image}, newFile, false)
assert.NoError(t, err)
})
db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID))
db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything)
}