+ Scaffold — UI features land in subsequent plans.
+
+
+
+```
+
+- [ ] **Step 11: Create `web/.gitignore`**
+
+```
+node_modules/
+.svelte-kit/
+build/*
+!build/index.html
+```
+
+- [ ] **Step 12: Create placeholder `web/static/favicon.png`**
+
+Run:
+```bash
+mkdir -p web/static
+python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='))" > web/static/favicon.png
+```
+
+Expected: `web/static/favicon.png` is a valid 1×1 PNG (67 bytes).
+
+- [ ] **Step 13: Create placeholder `web/build/index.html`**
+
+This placeholder lets `//go:embed build` compile before anyone runs `npm run build`. Real `npm run build` overwrites it locally; only this hand-written version is committed.
+
+```html
+
+
+
+
+ Minstrel
+
+
+
+ Minstrel SPA has not been built. Run
+ cd web && npm install && npm run build or
+ build the container image, which runs the web build during
+ docker build.
+
+
+
+```
+
+- [ ] **Step 14: Install dependencies**
+
+Run: `cd web && npm install`
+Expected: `node_modules/` populated, `package-lock.json` written, exit 0.
+
+- [ ] **Step 15: Verify SvelteKit sync + build**
+
+Run: `cd web && npm run build`
+Expected: exit 0; `web/build/` now contains `index.html`, `_app/`, `favicon.png`. (Local `index.html` is overwritten but not committed — `.gitignore` keeps only the committed placeholder under version control.)
+
+Undo the local overwrite so the committed placeholder remains:
+
+Run: `git checkout -- web/build/index.html`
+Expected: placeholder restored.
+
+- [ ] **Step 16: Commit**
+
+```bash
+git add web/.gitignore web/package.json web/package-lock.json web/tsconfig.json \
+ web/svelte.config.js web/vite.config.ts web/tailwind.config.js web/postcss.config.cjs \
+ web/src/ web/static/ web/build/index.html
+git commit -m "feat(web): scaffold SvelteKit SPA with Tailwind + adapter-static
+
+- SvelteKit 2.x + Svelte 5 + TypeScript + Vite 5
+- adapter-static with fallback:'index.html' for SPA deep-link routing
+- Tailwind configured with dark-only palette matching spec
+- Placeholder landing page at /
+- Committed web/build/index.html placeholder (via .gitignore negation) so
+ go:embed works before the SvelteKit build has been run"
+```
+
+---
+
+## Task 2: Vitest smoke test
+
+**Files:**
+- Create: `web/vitest.config.ts`
+- Create: `web/src/lib/example.test.ts`
+
+- [ ] **Step 1: Create `web/vitest.config.ts`**
+
+```ts
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.test.ts']
+ }
+});
+```
+
+- [ ] **Step 2: Write failing smoke test**
+
+Create `web/src/lib/example.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+
+describe('scaffold smoke', () => {
+ it('runs vitest', () => {
+ expect(1 + 1).toBe(2);
+ });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `cd web && npm test`
+Expected: 1 test passes. Exit 0.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/vitest.config.ts web/src/lib/example.test.ts
+git commit -m "test(web): add Vitest smoke test and jsdom config"
+```
+
+---
+
+## Task 3: Go embed package + SPA fallback handler
+
+**Files:**
+- Create: `web/embed.go`
+- Create: `web/embed_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `web/embed_test.go`:
+
+```go
+package web
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestHandler_ServesIndexAtRoot(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html*", ct)
+ }
+ body, _ := io.ReadAll(rec.Body)
+ if !strings.Contains(strings.ToLower(string(body)), " build/index.html
+// - GET / -> that file, via http.FileServer
+// - GET / -> build/index.html (SPA fallback)
+//
+// Callers are expected to register this as the router's NotFound/catch-all so
+// explicitly-routed paths (like /api/* and /rest/*) take precedence.
+func Handler() http.Handler {
+ sub, err := fs.Sub(buildFS, "build")
+ if err != nil {
+ // fs.Sub on a valid embed path can only fail if the embed directive
+ // is malformed, which the compile would have caught.
+ panic("web: fs.Sub(build) failed: " + err.Error())
+ }
+
+ index, err := fs.ReadFile(sub, "index.html")
+ if err != nil {
+ panic("web: embedded build/index.html missing: " + err.Error())
+ }
+
+ fileServer := http.FileServer(http.FS(sub))
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ clean := path.Clean(r.URL.Path)
+ if clean == "/" || clean == "." {
+ serveIndex(w, index)
+ return
+ }
+ name := strings.TrimPrefix(clean, "/")
+ if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() {
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ serveIndex(w, index)
+ })
+}
+
+func serveIndex(w http.ResponseWriter, index []byte) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(index)
+}
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+Run: `go test ./web/ -v`
+Expected: `TestHandler_ServesIndexAtRoot`, `TestHandler_UnknownPathFallsBackToIndex`, `TestHandler_MissingAssetFallsBackToIndex` all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/
+git commit -m "feat(web): embed SvelteKit build output with SPA fallback handler"
+```
+
+---
+
+## Task 4: Wire web handler into server router
+
+**Files:**
+- Modify: `internal/server/server.go`
+- Modify: `internal/server/server_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `internal/server/server_test.go` (also add `"strings"` to the imports):
+
+```go
+func TestRouter_ServesSPAAtRoot(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/")
+ if err != nil {
+ t.Fatalf("GET /: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html*", ct)
+ }
+}
+
+func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001")
+ if err != nil {
+ t.Fatalf("GET deep link: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html*", ct)
+ }
+}
+
+func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/api/does-not-exist")
+ if err != nil {
+ t.Fatalf("GET /api/does-not-exist: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q; /api/* must never return text/html", ct)
+ }
+}
+
+func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
+ s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{})
+ ts := httptest.NewServer(s.Router())
+ defer ts.Close()
+
+ resp, err := http.Get(ts.URL + "/rest/does-not-exist")
+ if err != nil {
+ t.Fatalf("GET /rest/does-not-exist: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
+ t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/server/ -v`
+Expected: FAIL on all four new tests (chi returns its own 404 with empty/plain body for unmatched routes).
+
+- [ ] **Step 3: Wire `web.Handler()` into `Router()`**
+
+Modify `internal/server/server.go`:
+
+a) Add imports (preserving alphabetical order within the group):
+
+```go
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/api"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
+ "git.fabledsword.com/bvandeusen/minstrel/web"
+)
+```
+
+b) Replace the `Router()` method with:
+
+```go
+func (s *Server) Router() http.Handler {
+ r := chi.NewRouter()
+ r.Use(middleware.RequestID)
+ r.Use(middleware.Recoverer)
+
+ r.Get("/healthz", s.handleHealthz)
+
+ if s.Pool != nil {
+ api.Mount(r, s.Pool, s.Logger)
+ r.Route("/api/admin", func(admin chi.Router) {
+ admin.Use(auth.RequireAdmin(s.Pool))
+ if s.Scanner != nil {
+ admin.Post("/scan", s.handleAdminScan)
+ }
+ })
+ subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
+ }
+
+ spa := web.Handler()
+ r.NotFound(func(w http.ResponseWriter, req *http.Request) {
+ p := req.URL.Path
+ if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
+ return
+ }
+ spa.ServeHTTP(w, req)
+ })
+ return r
+}
+```
+
+- [ ] **Step 4: Run tests — expect PASS**
+
+Run: `go test ./internal/server/ -v`
+Expected: all tests PASS, including the existing `TestHealthz` and the four new ones.
+
+- [ ] **Step 5: Run full test suite**
+
+Run: `go test -short -race ./...`
+Expected: PASS.
+
+- [ ] **Step 6: Run linter**
+
+Run: `golangci-lint run ./...`
+Expected: no issues.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/server/
+git commit -m "feat(server): serve embedded SPA at root with /api,/rest carve-out
+
+- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404
+- Everything else falls through to the embedded web handler, which
+ resolves static assets or returns index.html for SPA deep links"
+```
+
+---
+
+## Task 5: Multi-stage Dockerfile
+
+**Files:**
+- Rewrite: `Dockerfile`
+
+- [ ] **Step 1: Replace `Dockerfile` contents**
+
+```dockerfile
+# syntax=docker/dockerfile:1.6
+
+FROM node:22-bookworm-slim AS web
+WORKDIR /web
+COPY web/package.json web/package-lock.json ./
+RUN npm ci
+COPY web/ ./
+RUN npm run build
+
+FROM golang:1.23-bookworm AS builder
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+# Overwrite the committed placeholder with the freshly-built SPA assets.
+COPY --from=web /web/build ./web/build
+ENV CGO_ENABLED=0
+RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
+
+FROM debian:bookworm-slim
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN groupadd --system --gid 1000 minstrel \
+ && useradd --system --uid 1000 --gid minstrel --shell /usr/sbin/nologin minstrel
+
+COPY --from=builder /out/minstrel /usr/local/bin/minstrel
+COPY config.example.yaml /etc/smartmusic/config.yaml
+
+USER minstrel
+EXPOSE 4533
+ENTRYPOINT ["/usr/local/bin/minstrel"]
+CMD ["--config", "/etc/smartmusic/config.yaml"]
+```
+
+- [ ] **Step 2: Build image locally**
+
+Run: `docker build -t minstrel:scaffold-smoke .`
+Expected: all three stages succeed. The final layer is `FROM debian:bookworm-slim`, the binary exists at `/usr/local/bin/minstrel`.
+
+- [ ] **Step 3: Inspect image for SPA assets**
+
+Run: `docker run --rm --entrypoint /bin/sh minstrel:scaffold-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
+Expected: output `ok` (verifies the binary was copied in and is executable).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Dockerfile
+git commit -m "build: multi-stage Dockerfile with node->go->slim for embedded SPA
+
+Web assets are built in the node stage, copied into the go stage before
+go build so //go:embed picks them up, then the minimal slim runtime
+carries only the final binary and ffmpeg."
+```
+
+---
+
+## Task 6: CI — install Node and build web before Go
+
+**Files:**
+- Modify: `.forgejo/workflows/test.yml`
+
+- [ ] **Step 1: Replace `.forgejo/workflows/test.yml` contents**
+
+```yaml
+name: test
+
+# Lint + short unit tests. Integration tests needing Postgres/ffmpeg run
+# locally via docker-compose; they should guard with testing.Short().
+
+on:
+ push:
+ branches: [dev]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: go-ci
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+ cache-dependency-path: web/package-lock.json
+
+ - name: Install web deps
+ working-directory: web
+ run: npm ci
+
+ - name: Web build (populates web/build/ for go:embed)
+ working-directory: web
+ run: npm run build
+
+ - name: Web test (vitest)
+ working-directory: web
+ run: npm test
+
+ - name: Detect Go project
+ id: gomod
+ shell: bash
+ run: |
+ if [ -f go.mod ]; then
+ echo "present=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "present=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::No go.mod yet — Go steps will be skipped until the skeleton lands"
+ fi
+
+ - name: Toolchain versions
+ if: steps.gomod.outputs.present == 'true'
+ run: |
+ go version
+ golangci-lint --version
+
+ - name: go vet
+ if: steps.gomod.outputs.present == 'true'
+ run: go vet ./...
+
+ - name: golangci-lint
+ if: steps.gomod.outputs.present == 'true'
+ run: golangci-lint run ./...
+
+ - name: go test (short, race)
+ if: steps.gomod.outputs.present == 'true'
+ run: go test -short -race ./...
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add .forgejo/workflows/test.yml
+git commit -m "ci: install Node + build web before Go steps
+
+Go's //go:embed needs web/build/ to exist at compile time. CI now
+runs 'npm ci && npm run build && npm test' ahead of the Go steps
+so the embed directive sees real, freshly-built assets."
+```
+
+---
+
+## Task 7: README dev-workflow section
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Read current README**
+
+Run: `cat README.md`
+Note the existing structure (headings, sections).
+
+- [ ] **Step 2: Append or replace a "Development" section**
+
+Use the Edit tool to insert the following section. Place it after any existing "Getting started" or "Quickstart" section; if none exists, append to the end of the file.
+
+```markdown
+## Development
+
+Minstrel has two concurrent dev processes:
+
+1. **Backend:** `docker compose up` — starts Postgres and Minstrel on `:4533`.
+2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR.
+
+The Vite dev server proxies `/api/*` and `/rest/*` to the backend on `:4533`,
+so session cookies work correctly when the SPA is loaded from the Vite origin.
+
+### Production build
+
+`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage,
+copies the output into the `golang` stage, and `//go:embed`s it into the
+final binary. The resulting container serves the SPA from `/` alongside the
+API surfaces. No separate static-file server is required.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: document two-process dev workflow with Vite proxy"
+```
+
+---
+
+## Task 8: Final verification
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Full Go suite**
+
+Run: `go test -short -race ./...`
+Expected: PASS.
+
+- [ ] **Step 2: Go linter**
+
+Run: `golangci-lint run ./...`
+Expected: no issues.
+
+- [ ] **Step 3: Web tests + build**
+
+Run: `cd web && npm test && npm run build`
+Expected: PASS. Verify `web/build/index.html` was regenerated; then:
+
+Run: `git checkout -- web/build/index.html`
+Expected: committed placeholder restored (the fresh build output stays out of version control).
+
+- [ ] **Step 4: Docker build smoke**
+
+Run: `docker build -t minstrel:scaffold-final . && docker image inspect minstrel:scaffold-final --format '{{.Size}}'`
+Expected: image builds; size printed (bytes).
+
+- [ ] **Step 5: Local end-to-end**
+
+Bring up the stack:
+```bash
+docker compose up --build -d
+```
+
+Then in another terminal:
+```bash
+curl -sI http://localhost:4533/
+curl -sI http://localhost:4533/artists/00000000-0000-0000-0000-000000000001
+curl -sI http://localhost:4533/healthz
+curl -sI http://localhost:4533/api/nonexistent
+```
+
+Expected responses:
+- `GET /` → `200 OK`, `Content-Type: text/html; charset=utf-8`.
+- `GET /artists/…` → `200 OK`, `Content-Type: text/html; charset=utf-8` (SPA fallback).
+- `GET /healthz` → `200 OK`, `Content-Type: application/json`.
+- `GET /api/nonexistent` → `401 Unauthorized` (RequireUser kicks in before the NotFound wrapper for mounted `/api/*` routes) OR `404 Not Found` with `Content-Type: application/json` (not `text/html`). Either is acceptable — the key requirement is **no `text/html` body**.
+
+Tear down:
+```bash
+docker compose down
+```
+
+- [ ] **Step 6: Finish the branch**
+
+Follow `superpowers:finishing-a-development-branch`: present the four-option menu and proceed per the user's choice. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
diff --git a/web/src/routes/albums/[id]/album.test.ts b/web/src/routes/albums/[id]/album.test.ts
index d9a98b87..fa5673b1 100644
--- a/web/src/routes/albums/[id]/album.test.ts
+++ b/web/src/routes/albums/[id]/album.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { flushSync } from 'svelte';
+import { readable } from 'svelte/store';
import { mockQuery } from '../../../test-utils/query';
import type { AlbumDetail, TrackRef } from '$lib/api/types';
@@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({
createAlbumQuery: vi.fn()
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import AlbumPage from './+page.svelte';
import { createAlbumQuery } from '$lib/api/queries';
diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts
index cc005579..25d8beb1 100644
--- a/web/src/routes/artists.test.ts
+++ b/web/src/routes/artists.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { flushSync } from 'svelte';
+import { readable } from 'svelte/store';
import { mockInfiniteQuery } from '../test-utils/query';
import type { ArtistRef, Page } from '$lib/api/types';
@@ -19,6 +20,21 @@ vi.mock('$lib/api/queries', () => ({
createArtistsQuery: vi.fn()
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import ArtistsPage from './+page.svelte';
import { createArtistsQuery } from '$lib/api/queries';
import { goto } from '$app/navigation';
diff --git a/web/src/routes/artists/[id]/artist.test.ts b/web/src/routes/artists/[id]/artist.test.ts
index 3d3381b3..db4a332c 100644
--- a/web/src/routes/artists/[id]/artist.test.ts
+++ b/web/src/routes/artists/[id]/artist.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { flushSync } from 'svelte';
+import { readable } from 'svelte/store';
import { mockQuery } from '../../../test-utils/query';
import type { ArtistDetail, AlbumRef } from '$lib/api/types';
@@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({
createArtistQuery: vi.fn()
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import ArtistPage from './+page.svelte';
import { createArtistQuery } from '$lib/api/queries';
diff --git a/web/src/routes/search/albums/albums.test.ts b/web/src/routes/search/albums/albums.test.ts
index f48b8781..32eaed58 100644
--- a/web/src/routes/search/albums/albums.test.ts
+++ b/web/src/routes/search/albums/albums.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { AlbumRef, Page } from '$lib/api/types';
@@ -20,6 +21,21 @@ vi.mock('$lib/player/store.svelte', () => ({
enqueueTracks: vi.fn()
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import AlbumsOverflow from './+page.svelte';
import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
diff --git a/web/src/routes/search/artists/artists.test.ts b/web/src/routes/search/artists/artists.test.ts
index 607ed39e..0c18d635 100644
--- a/web/src/routes/search/artists/artists.test.ts
+++ b/web/src/routes/search/artists/artists.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { ArtistRef, Page } from '$lib/api/types';
@@ -13,6 +14,21 @@ vi.mock('$lib/api/queries', () => ({
createSearchArtistsInfiniteQuery: vi.fn()
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import ArtistsOverflow from './+page.svelte';
import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
diff --git a/web/src/routes/search/search.test.ts b/web/src/routes/search/search.test.ts
index 8aab5c7d..4c4927b6 100644
--- a/web/src/routes/search/search.test.ts
+++ b/web/src/routes/search/search.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
import { mockQuery } from '../../test-utils/query';
import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types';
@@ -26,6 +27,21 @@ vi.mock('$lib/api/client', () => ({
api: { get: vi.fn() }
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import SearchPage from './+page.svelte';
import { createSearchQuery } from '$lib/api/queries';
diff --git a/web/src/routes/search/tracks/tracks.test.ts b/web/src/routes/search/tracks/tracks.test.ts
index 5eb9c300..4d515483 100644
--- a/web/src/routes/search/tracks/tracks.test.ts
+++ b/web/src/routes/search/tracks/tracks.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
+import { readable } from 'svelte/store';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { TrackRef, Page } from '$lib/api/types';
@@ -19,6 +20,21 @@ vi.mock('$lib/player/store.svelte', () => ({
enqueueTrack: vi.fn()
}));
+vi.mock('$lib/api/likes', () => ({
+ createLikedIdsQuery: () => readable({
+ data: { track_ids: [], album_ids: [], artist_ids: [] },
+ isPending: false,
+ isError: false
+ }),
+ likeEntity: vi.fn(),
+ unlikeEntity: vi.fn()
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({}) };
+});
+
import TracksOverflow from './+page.svelte';
import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
import { playRadio } from '$lib/player/store.svelte';