fix(web): add likes/QueryClient mocks to route tests rendering LikeButton
Pages that render TrackRow/AlbumCard/ArtistRow/PlayerBar now indirectly mount LikeButton, which calls useQueryClient(). Route tests need the same mocks the component-level tests have. - Added createLikedIdsQuery mock to 7 route test files - Added useQueryClient mock to all affected route tests - Added readable store import where needed
This commit is contained in:
Executable
BIN
Binary file not shown.
@@ -0,0 +1,954 @@
|
|||||||
|
# Web UI Scaffold Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Scaffold a SvelteKit SPA at `web/`, embed its build output into the Go binary, and update the Dockerfile + CI so a single container image serves the SPA at `/` alongside the existing `/api/*` and `/rest/*` surfaces.
|
||||||
|
|
||||||
|
**Architecture:** SvelteKit with `@sveltejs/adapter-static` (SPA fallback to `index.html`) builds to `web/build/`. A thin `web` Go package `//go:embed`s that directory and returns an `http.Handler` that serves static assets and falls back to `index.html` for unknown paths. The handler is wired as the server's `NotFound` route so explicit routes (`/api`, `/rest`, `/healthz`) are unaffected; the `NotFound` wrapper also returns a JSON 404 for unmatched `/api/*` and `/rest/*` paths so the SPA fallback never produces HTML bodies for API consumers. A hand-crafted placeholder `web/build/index.html` is committed (via negated gitignore) so `go build` works in clean checkouts before anyone runs `npm run build`.
|
||||||
|
|
||||||
|
**Tech Stack:** SvelteKit 2.x, Svelte 5, TypeScript 5, Vite 5, Tailwind CSS 3, Vitest, Go 1.23, chi/v5, Docker multi-stage builds, Forgejo Actions.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`. This plan implements only the Stack / Packaging / Dev-workflow / Build sections. Feature UI (login, library views, player) is out of scope and lands in follow-up plans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `web/package.json` | Create | npm config + scripts |
|
||||||
|
| `web/tsconfig.json` | Create | TypeScript config extending SvelteKit's generated one |
|
||||||
|
| `web/svelte.config.js` | Create | `adapter-static` with `fallback: 'index.html'` |
|
||||||
|
| `web/vite.config.ts` | Create | Vite config + dev proxy for `/api` and `/rest` |
|
||||||
|
| `web/vitest.config.ts` | Create | Vitest + jsdom config |
|
||||||
|
| `web/tailwind.config.js` | Create | Tailwind content globs + dark palette tokens |
|
||||||
|
| `web/postcss.config.cjs` | Create | PostCSS pipeline (tailwindcss + autoprefixer) |
|
||||||
|
| `web/src/app.html` | Create | HTML template |
|
||||||
|
| `web/src/app.css` | Create | Tailwind directives + CSS custom properties |
|
||||||
|
| `web/src/routes/+layout.svelte` | Create | Imports `app.css`, renders outlet |
|
||||||
|
| `web/src/routes/+page.svelte` | Create | Placeholder landing page |
|
||||||
|
| `web/src/lib/example.test.ts` | Create | Vitest smoke test |
|
||||||
|
| `web/static/favicon.png` | Create | 1×1 transparent PNG placeholder |
|
||||||
|
| `web/build/index.html` | Create | Hand-crafted placeholder committed so `go:embed` works pre-build |
|
||||||
|
| `web/.gitignore` | Create | Ignore `node_modules/`, `.svelte-kit/`, `build/*` with `!build/index.html` |
|
||||||
|
| `web/embed.go` | Create | `//go:embed` build tree + `Handler()` with SPA fallback (must live next to `web/build/` — `go:embed` cannot reach parent directories) |
|
||||||
|
| `web/embed_test.go` | Create | Unit tests for the handler |
|
||||||
|
| `internal/server/server.go` | Modify | Wire `web.Handler()` into `NotFound`; JSON-404 for `/api/*` and `/rest/*` |
|
||||||
|
| `internal/server/server_test.go` | Modify | Assert `/` returns HTML; `/api/anything` does not return HTML |
|
||||||
|
| `Dockerfile` | Rewrite | Three-stage build: `node` → `golang` → `debian:slim` |
|
||||||
|
| `.forgejo/workflows/test.yml` | Modify | Install Node, build web, run `npm test` before Go steps |
|
||||||
|
| `README.md` | Modify | Document two-process dev workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: SvelteKit project skeleton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/package.json`, `web/tsconfig.json`, `web/svelte.config.js`, `web/vite.config.ts`, `web/tailwind.config.js`, `web/postcss.config.cjs`
|
||||||
|
- Create: `web/src/app.html`, `web/src/app.css`, `web/src/routes/+layout.svelte`, `web/src/routes/+page.svelte`
|
||||||
|
- Create: `web/.gitignore`, `web/static/favicon.png`, `web/build/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `web/package.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "minstrel-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "svelte-kit sync && vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.8.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"svelte": "^5.1.9",
|
||||||
|
"svelte-check": "^4.0.5",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tslib": "^2.8.0",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `web/tsconfig.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `web/svelte.config.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: false
|
||||||
|
}),
|
||||||
|
files: {
|
||||||
|
assets: 'static'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `web/vite.config.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:4533',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/rest': {
|
||||||
|
target: 'http://localhost:4533',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create `web/tailwind.config.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{html,js,ts,svelte}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
900: '#14161a',
|
||||||
|
800: '#1a1d22',
|
||||||
|
700: '#2a2f36'
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#e8ecf2',
|
||||||
|
secondary: '#cdd3db'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create `web/postcss.config.cjs`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Create `web/src/app.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Minstrel</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface-900 text-text-primary">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Create `web/src/app.css`**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Create `web/src/routes/+layout.svelte`**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Create `web/src/routes/+page.svelte`**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<main class="flex h-screen items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-4xl font-semibold">Minstrel</h1>
|
||||||
|
<p class="mt-2 text-text-secondary">
|
||||||
|
Scaffold — UI features land in subsequent plans.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Minstrel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>
|
||||||
|
Minstrel SPA has not been built. Run
|
||||||
|
<code>cd web && npm install && npm run build</code> or
|
||||||
|
build the container image, which runs the web build during
|
||||||
|
<code>docker build</code>.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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)), "<html") {
|
||||||
|
t.Errorf("body missing <html tag; got: %q", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_UnknownPathFallsBackToIndex(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/artists/some-uuid", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/html* for SPA fallback", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_MissingAssetFallsBackToIndex(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_app/immutable/chunks/does-not-exist.js", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (fallback to index.html)", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/html* for missing asset", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `go test ./web/ -v`
|
||||||
|
Expected: FAIL — `Handler` undefined / package missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `web/embed.go`**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
|
||||||
|
// Requests for real files under build/ are served verbatim; anything else —
|
||||||
|
// deep links like /artists/abc, missing asset paths — returns build/index.html
|
||||||
|
// so the SPA can resolve the route client-side.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:build
|
||||||
|
var buildFS embed.FS
|
||||||
|
|
||||||
|
// Handler returns an http.Handler that serves the embedded SPA.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - GET / -> build/index.html
|
||||||
|
// - GET /<path that exists in build/> -> that file, via http.FileServer
|
||||||
|
// - GET /<anything else> -> 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.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { flushSync } from 'svelte';
|
import { flushSync } from 'svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockQuery } from '../../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
import type { AlbumDetail, TrackRef } from '$lib/api/types';
|
import type { AlbumDetail, TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({
|
|||||||
createAlbumQuery: vi.fn()
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import AlbumPage from './+page.svelte';
|
import AlbumPage from './+page.svelte';
|
||||||
import { createAlbumQuery } from '$lib/api/queries';
|
import { createAlbumQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { flushSync } from 'svelte';
|
import { flushSync } from 'svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockInfiniteQuery } from '../test-utils/query';
|
import { mockInfiniteQuery } from '../test-utils/query';
|
||||||
import type { ArtistRef, Page } from '$lib/api/types';
|
import type { ArtistRef, Page } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -19,6 +20,21 @@ vi.mock('$lib/api/queries', () => ({
|
|||||||
createArtistsQuery: vi.fn()
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import ArtistsPage from './+page.svelte';
|
import ArtistsPage from './+page.svelte';
|
||||||
import { createArtistsQuery } from '$lib/api/queries';
|
import { createArtistsQuery } from '$lib/api/queries';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { flushSync } from 'svelte';
|
import { flushSync } from 'svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockQuery } from '../../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
import type { ArtistDetail, AlbumRef } from '$lib/api/types';
|
import type { ArtistDetail, AlbumRef } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -18,6 +19,21 @@ vi.mock('$lib/api/queries', () => ({
|
|||||||
createArtistQuery: vi.fn()
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import ArtistPage from './+page.svelte';
|
import ArtistPage from './+page.svelte';
|
||||||
import { createArtistQuery } from '$lib/api/queries';
|
import { createArtistQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||||
import type { AlbumRef, Page } from '$lib/api/types';
|
import type { AlbumRef, Page } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -20,6 +21,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
enqueueTracks: vi.fn()
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import AlbumsOverflow from './+page.svelte';
|
import AlbumsOverflow from './+page.svelte';
|
||||||
import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
|
import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||||
import type { ArtistRef, Page } from '$lib/api/types';
|
import type { ArtistRef, Page } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -13,6 +14,21 @@ vi.mock('$lib/api/queries', () => ({
|
|||||||
createSearchArtistsInfiniteQuery: vi.fn()
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import ArtistsOverflow from './+page.svelte';
|
import ArtistsOverflow from './+page.svelte';
|
||||||
import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
|
import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockQuery } from '../../test-utils/query';
|
import { mockQuery } from '../../test-utils/query';
|
||||||
import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types';
|
import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -26,6 +27,21 @@ vi.mock('$lib/api/client', () => ({
|
|||||||
api: { get: vi.fn() }
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import SearchPage from './+page.svelte';
|
import SearchPage from './+page.svelte';
|
||||||
import { createSearchQuery } from '$lib/api/queries';
|
import { createSearchQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||||||
import type { TrackRef, Page } from '$lib/api/types';
|
import type { TrackRef, Page } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -19,6 +20,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
enqueueTrack: vi.fn()
|
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<string, unknown>;
|
||||||
|
return { ...actual, useQueryClient: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
import TracksOverflow from './+page.svelte';
|
import TracksOverflow from './+page.svelte';
|
||||||
import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
|
import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
|
||||||
import { playRadio } from '$lib/player/store.svelte';
|
import { playRadio } from '$lib/player/store.svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user