diff --git a/frontend/package.json b/frontend/package.json
index d68122c..5e230c5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,6 +26,8 @@
"vue-tsc": "^2.0.0",
"vite-plugin-vuetify": "^2.0.0",
"sass": "^1.71.0",
- "vitest": "^2.1.0"
+ "vitest": "^2.1.0",
+ "@vue/test-utils": "^2.4.0",
+ "happy-dom": "^15.0.0"
}
}
diff --git a/frontend/test/components/activeDownloadsPanel.spec.js b/frontend/test/components/activeDownloadsPanel.spec.js
new file mode 100644
index 0000000..e6e683e
--- /dev/null
+++ b/frontend/test/components/activeDownloadsPanel.spec.js
@@ -0,0 +1,30 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest'
+
+import ActiveDownloadsPanel from '../../src/components/subscriptions/ActiveDownloadsPanel.vue'
+import { useDownloadsStore } from '../../src/stores/downloads.js'
+import { freshPinia, mountComponent } from '../support/mountComponent.js'
+
+describe('ActiveDownloadsPanel', () => {
+ it('lists a running download with its artist', () => {
+ const pinia = freshPinia()
+ useDownloadsStore().activeEvents = [{
+ id: 1, status: 'running',
+ started_at: new Date(Date.now() - 65000).toISOString(),
+ platform: 'patreon', artist_name: 'Alice',
+ }]
+ const w = mountComponent(ActiveDownloadsPanel, { pinia })
+ const t = w.text()
+ expect(t).toContain('Alice')
+ expect(t).toContain('downloading')
+ w.unmount() // clear the 1s elapsed-timer interval
+ })
+
+ it('renders nothing when there is no active work', () => {
+ const pinia = freshPinia()
+ useDownloadsStore().activeEvents = []
+ const w = mountComponent(ActiveDownloadsPanel, { pinia })
+ expect(w.text()).toBe('')
+ w.unmount()
+ })
+})
diff --git a/frontend/test/components/credentialCard.spec.js b/frontend/test/components/credentialCard.spec.js
new file mode 100644
index 0000000..f98d39b
--- /dev/null
+++ b/frontend/test/components/credentialCard.spec.js
@@ -0,0 +1,28 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest'
+
+import CredentialCard from '../../src/components/subscriptions/CredentialCard.vue'
+import { freshPinia, mountComponent } from '../support/mountComponent.js'
+
+const platform = { key: 'patreon', name: 'Patreon', auth_type: 'cookies' }
+
+describe('CredentialCard', () => {
+ it('renders the last-verified row when last_verified is set', () => {
+ // Regression guard: the field was once read as last_verified_at, so the
+ // row silently never rendered.
+ const pinia = freshPinia()
+ const recent = new Date(Date.now() - 2 * 86400_000).toISOString()
+ const credential = {
+ platform: 'patreon', credential_type: 'cookies',
+ captured_at: recent, expires_at: null, last_verified: recent,
+ }
+ const w = mountComponent(CredentialCard, { props: { platform, credential }, pinia })
+ expect(w.text()).toContain('Last verified')
+ })
+
+ it('renders the empty state with no credential', () => {
+ const pinia = freshPinia()
+ const w = mountComponent(CredentialCard, { props: { platform, credential: null }, pinia })
+ expect(w.text()).toContain('No credential stored')
+ })
+})
diff --git a/frontend/test/components/downloadEventRow.spec.js b/frontend/test/components/downloadEventRow.spec.js
new file mode 100644
index 0000000..17d2151
--- /dev/null
+++ b/frontend/test/components/downloadEventRow.spec.js
@@ -0,0 +1,21 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest'
+
+import DownloadEventRow from '../../src/components/downloads/DownloadEventRow.vue'
+import { freshPinia, mountComponent } from '../support/mountComponent.js'
+
+describe('DownloadEventRow', () => {
+ it('renders the artist and the mapped status label', () => {
+ const pinia = freshPinia()
+ const now = new Date().toISOString()
+ const event = {
+ id: 1, status: 'ok', started_at: now, finished_at: now,
+ platform: 'patreon', artist_name: 'Alice',
+ files_count: 3, bytes_downloaded: 1000, error: null, summary: {},
+ }
+ const w = mountComponent(DownloadEventRow, { props: { event }, pinia })
+ const t = w.text()
+ expect(t).toContain('Alice')
+ expect(t).toContain('Completed') // downloadStatusLabel('ok')
+ })
+})
diff --git a/frontend/test/components/postCard.spec.js b/frontend/test/components/postCard.spec.js
new file mode 100644
index 0000000..1e39004
--- /dev/null
+++ b/frontend/test/components/postCard.spec.js
@@ -0,0 +1,26 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest'
+
+import PostCard from '../../src/components/posts/PostCard.vue'
+import { freshPinia, mountComponent } from '../support/mountComponent.js'
+
+describe('PostCard', () => {
+ it('renders the HTML-stripped title and the artist', () => {
+ const pinia = freshPinia()
+ const now = new Date().toISOString()
+ const post = {
+ id: 1, external_post_id: 'P1',
+ post_title: 'Hello World',
+ post_url: 'https://x/1', post_date: now, downloaded_at: now,
+ description_plain: 'a description',
+ artist: { id: 1, name: 'Sabu', slug: 'sabu' },
+ source: { id: 1, platform: 'subscribestar' },
+ thumbnails: [], thumbnails_more: 0, attachments: [],
+ }
+ const w = mountComponent(PostCard, { props: { post }, pinia })
+ const t = w.text()
+ expect(t).toContain('Hello World') // plain title
+ expect(t).not.toContain('') // tags stripped
+ expect(t).toContain('Sabu') // artist (RouterLink slot)
+ })
+})
diff --git a/frontend/test/components/queueStatusBar.spec.js b/frontend/test/components/queueStatusBar.spec.js
new file mode 100644
index 0000000..cb65599
--- /dev/null
+++ b/frontend/test/components/queueStatusBar.spec.js
@@ -0,0 +1,26 @@
+// @vitest-environment happy-dom
+import { describe, it, expect } from 'vitest'
+
+import QueueStatusBar from '../../src/components/settings/QueueStatusBar.vue'
+import { useSystemActivityStore } from '../../src/stores/systemActivity.js'
+import { freshPinia, mountComponent } from '../support/mountComponent.js'
+
+describe('QueueStatusBar', () => {
+ it('shows the pending count when the queue is busy', () => {
+ const pinia = freshPinia()
+ useSystemActivityStore().queues = { queues: { ml: 3 }, fetched_at: '' }
+ const w = mountComponent(QueueStatusBar, {
+ props: { queue: 'ml', queueLabel: 'ML' }, pinia,
+ })
+ expect(w.text()).toContain('3 pending')
+ })
+
+ it('reads idle when the queue is empty', () => {
+ const pinia = freshPinia()
+ useSystemActivityStore().queues = { queues: { ml: 0 }, fetched_at: '' }
+ const w = mountComponent(QueueStatusBar, {
+ props: { queue: 'ml', queueLabel: 'ML' }, pinia,
+ })
+ expect(w.text().toLowerCase()).toContain('idle')
+ })
+})
diff --git a/frontend/test/support/mountComponent.js b/frontend/test/support/mountComponent.js
new file mode 100644
index 0000000..888a70a
--- /dev/null
+++ b/frontend/test/support/mountComponent.js
@@ -0,0 +1,24 @@
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+
+// Smoke-test helper. Vuetify components are left unresolved (Vue 3 renders
+// unknown elements with their slot children, so text content is still
+// present and nothing throws), which avoids standing up the whole Vuetify
+// plugin in happy-dom. RouterLink is the one import that needs a router, so
+// it's stubbed with a slot-rendering anchor.
+
+export function freshPinia () {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ return pinia
+}
+
+export function mountComponent (Component, { props = {}, pinia } = {}) {
+ return mount(Component, {
+ props,
+ global: {
+ plugins: pinia ? [pinia] : [],
+ stubs: { RouterLink: { template: '' } },
+ },
+ })
+}