🏡 index : quine-global/quintodrome.git

package plugins

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"os"
	"path/filepath"
	"strconv"
	"time"

	extism "github.com/extism/go-sdk"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/db"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/persistence"
	"github.com/navidrome/navidrome/plugins/host"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// scrobblePage mirrors what the test plugin emits for the multi-return GetScrobbles
type scrobblePage struct {
	Scrobbles []host.ScrobbleRef    `json:"scrobbles"`
	Next      *host.ScrobbleOptions `json:"next"`
}

var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
	var (
		manager   *Manager
		tmpDir    string
		dataStore *tests.MockDataStore
	)

	p := func(val int64) *int64 {
		return &val
	}

	opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions {
		return &host.ScrobbleOptions{
			FromTimestamp: from, ToTimestamp: to,
			Descending: descending, Offset: offset, MaxItems: maxItems,
		}
	}

	BeforeAll(func() {
		ctx := GinkgoT().Context()

		var err error
		tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*")
		Expect(err).ToNot(HaveOccurred())

		conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")

		db.Init(ctx)
		DeferCleanup(func() {
			Expect(tests.ClearDB()).To(Succeed())
		})
		dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())}

		// Copy test plugin to temp dir
		srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension)
		destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension)
		data, err := os.ReadFile(srcPath)
		Expect(err).ToNot(HaveOccurred())
		err = os.WriteFile(destPath, data, 0600)
		Expect(err).ToNot(HaveOccurred())

		// Setup config
		DeferCleanup(configtest.SetupConfig())
		conf.Server.Plugins.Enabled = true
		conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
		conf.Server.Plugins.AutoReload = false

		userRepo := dataStore.User()
		// Add test users
		_ = userRepo.Put(ctx, &model.User{
			ID:       "user1",
			UserName: "testuser",
			IsAdmin:  false,
		})
		_ = userRepo.Put(ctx, &model.User{
			ID:       "admin1",
			UserName: "adminuser",
			IsAdmin:  true,
		})

		err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "1", LibraryID: 1})
		Expect(err).To(BeNil())
		err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "2", LibraryID: 1})
		Expect(err).To(BeNil())
		err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "3", LibraryID: 1})
		Expect(err).To(BeNil())

		scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"})

		scrobbleRepo := dataStore.Scrobble()
		err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(0, 0))
		Expect(err).To(BeNil())
		err = scrobbleRepo.RecordScrobble(scrobbleCtx, "2", time.Unix(1, 0))
		Expect(err).To(BeNil())
		err = scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(2, 0))
		Expect(err).To(BeNil())
		err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(2, 0))
		Expect(err).To(BeNil())

		// Create and configure manager
		manager = &Manager{
			plugins: make(map[string]*plugin),
			ds:      dataStore,
		}
		router := &fakeSubsonicRouter{}
		manager.SetSubsonicRouter(router)

		// Pre-enable the plugin in the mock repo so it loads on startup
		// Compute SHA256 of the plugin file to match what syncPlugins will compute
		pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension)
		wasmData, err := os.ReadFile(pluginPath)
		Expect(err).ToNot(HaveOccurred())
		hash := sha256.Sum256(wasmData)
		hashHex := hex.EncodeToString(hash[:])

		dataStore.MockedPlugin = tests.CreateMockPluginRepo()

		mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo)
		mockPluginRepo.Permitted = true
		enabledPlugin := model.Plugin{
			ID:      "test-scrobble-retriever",
			Path:    pluginPath,
			SHA256:  hashHex,
			Enabled: true,
			Users:   `["user1","admin1"]`,
		}
		mockPluginRepo.SetData(model.Plugins{enabledPlugin})

		// Start the manager
		err = manager.Start(GinkgoT().Context())
		Expect(err).ToNot(HaveOccurred())

		DeferCleanup(func() {
			_ = manager.Stop()
			_ = os.RemoveAll(tmpDir)
		})
	})

	var instance *extism.Plugin

	BeforeEach(func() {
		manager.mu.RLock()
		plugin := manager.plugins["test-scrobble-retriever"]
		manager.mu.RUnlock()
		Expect(plugin).ToNot(BeNil())

		var err error
		ctx := GinkgoT().Context()
		instance, err = plugin.instance(ctx)
		Expect(err).ToNot(HaveOccurred())
		DeferCleanup(func() {
			instance.Close(ctx)
		})
	})

	Describe("not authorized", func() {
		It("rejects first timestamp", func() {
			exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser"))
			Expect(err).To(HaveOccurred())
			Expect(exit).To(Equal(uint32(1)))
		})

		It("rejects last timestamp", func() {
			exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser"))
			Expect(err).To(HaveOccurred())
			Expect(exit).To(Equal(uint32(1)))
		})

		It("rejects scrobbles", func() {
			exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`))
			Expect(err).To(HaveOccurred())
			Expect(exit).To(Equal(uint32(1)))
		})

		It("rejects scrobbles", func() {
			exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`))
			Expect(err).To(HaveOccurred())
			Expect(exit).To(Equal(uint32(1)))
		})
	})

	Describe("no items", func() {
		It("calls get first timestamp", func() {
			exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser"))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			Expect(output).To(Equal([]byte("{\"timestamp\":null}")))
		})

		It("calls get last timestamp", func() {
			exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser"))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			Expect(output).To(Equal([]byte("{\"timestamp\":null}")))
		})

		It("calls scrobbles", func() {
			exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`)))
		})

		It("calls get scrobble count", func() {
			exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))
			Expect(output).To(Equal([]byte("0")))
		})
	})

	Describe("with items", func() {
		scrobbles := []host.ScrobbleRef{
			{ID: 1, MediaFileID: "1", SubmissionTime: 0},
			{ID: 2, MediaFileID: "2", SubmissionTime: 1},
			{ID: 3, MediaFileID: "3", SubmissionTime: 2},
			{ID: 4, MediaFileID: "1", SubmissionTime: 2},
		}

		scrobblesReversed := make([]host.ScrobbleRef, 4)

		BeforeAll(func() {
			for idx := range scrobbles {
				scrobblesReversed[3-idx] = scrobbles[idx]
			}
		})

		It("calls get first timestamp", func() {
			exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser"))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			Expect(output).To(Equal([]byte("{\"timestamp\":0}")))
		})

		It("calls get last timestamp", func() {
			exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser"))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			Expect(output).To(Equal([]byte("{\"timestamp\":2}")))
		})

		DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) {
			exit, output, err := instance.Call("call_get_scrobbles", []byte(params))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			var page scrobblePage
			Expect(json.Unmarshal(output, &page)).To(Succeed())
			Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next}))
		},
			Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil),
			Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil),
			Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil),
			Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil),
			Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)),
			Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)),
			Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil),
			Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil),
			Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil),
			Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)),
			Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)),
		)

		DescribeTable("GetScrobblesCount", func(params string, count int) {
			exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params))
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			value, err := strconv.ParseInt(string(output), 10, 64)
			Expect(err).ToNot(HaveOccurred())
			Expect(value).To(Equal(int64(count)))
		},
			Entry("gets all scrobbles", `{"username":"adminuser"}`, 4),
			Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2),
			Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1),
			Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1),
			Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4),
		)
	})

	Context("Complex edge cases - multiple scrobbles at the same timestamp", func() {
		duplicates := make([]host.ScrobbleRef, 5)
		duplicatesReversed := make([]host.ScrobbleRef, 5)

		BeforeAll(func() {
			scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"})

			scrobbleRepo := dataStore.Scrobble()

			for i := range 5 {
				err := scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(100, 0))
				Expect(err).To(BeNil())

				scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100}
				duplicates[i] = scrobble
				duplicatesReversed[4-i] = scrobble
			}
		})

		getPage := func(o host.ScrobbleOptions) scrobblePage {
			GinkgoHelper()
			payload, err := json.Marshal(struct {
				Username      string `json:"username"`
				FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
				ToTimestamp   *int64 `json:"toTimestamp,omitempty"`
				Descending    bool   `json:"descending"`
				Offset        int    `json:"offset,omitempty"`
				MaxItems      int    `json:"maxItems"`
			}{
				Username:      "adminuser",
				FromTimestamp: o.FromTimestamp,
				ToTimestamp:   o.ToTimestamp,
				Descending:    o.Descending,
				Offset:        o.Offset,
				MaxItems:      o.MaxItems,
			})
			Expect(err).ToNot(HaveOccurred())

			exit, output, err := instance.Call("call_get_scrobbles", payload)
			Expect(err).ToNot(HaveOccurred())
			Expect(exit).To(Equal(uint32(0)))

			var page scrobblePage
			Expect(json.Unmarshal(output, &page)).To(Succeed())
			return page
		}

		// every scrobble here shares timestamp 100, so only Offset advances between pages
		tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions {
			return *opts(p(100), p(100), descending, offset, maxItems)
		}

		DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) {
			var next *host.ScrobbleOptions
			if nextOffset != nil {
				next = opts(p(100), p(100), descending, *nextOffset, count)
			}
			Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next}))
		},
			Entry("All tracks, in order", 0, 100, false, duplicates, nil),
			Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil),
			Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)),
			Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)),
			Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil),
			Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)),
			Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)),
			Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)),
			Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil),
		)

		It("walks every page exactly once when all timestamps collide", func() {
			for _, descending := range []bool{false, true} {
				var seen []host.ScrobbleRef
				o := tied(0, 2, descending)
				var page scrobblePage
				for range 10 {
					page = getPage(o)
					seen = append(seen, page.Scrobbles...)
					if page.Next == nil {
						break
					}
					o = *page.Next
				}
				Expect(page.Next).To(BeNil(), "pagination did not terminate")
				if descending {
					Expect(seen).To(Equal(duplicatesReversed))
				} else {
					Expect(seen).To(Equal(duplicates))
				}
			}
		})
	})
})