ui/tests/watchers.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { ApiClient, ApiClientError } from '../src/api/client'
import { isUntrustedPayload } from '../src/utils/untrusted'
// Helper to mock fetch with a given status + body
function mockFetch(status: number, body: unknown): void {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
}))
}
beforeEach(() => {
vi.unstubAllGlobals()
setActivePinia(createPinia())
})
// ─── Watcher API client ───────────────────────────────────────────────────────
const minimalWatcher = {
id: 'wat_1',
name: 'Test feed',
kind: 'rss',
config: { url: 'https://example.com/feed.xml' },
keywords: [],
keywords_none: [],
min_score: 1,
schedule: { every: '1h' },
status: 'active',
fail_count: 0,
first_run_done: false,
next_run_at: 1000000,
created_at: 999000,
updated_at: 999000,
}
describe('ApiClient.createWatcher', () => {
it('sends POST to /watchers with the correct body', async () => {
mockFetch(201, minimalWatcher)
const client = new ApiClient('im_key')
await client.createWatcher({
name: 'Test feed',
kind: 'rss',
config: { url: 'https://example.com/feed.xml' },
schedule: { every: '1h' },
})
const [url, opts] = (vi.mocked(fetch)).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/watchers')
expect(opts.method).toBe('POST')
const body = JSON.parse(opts.body as string) as {
name: string
kind: string
config: { url: string }
schedule: { every: string }
}
expect(body.name).toBe('Test feed')
expect(body.kind).toBe('rss')
expect(body.config.url).toBe('https://example.com/feed.xml')
expect(body.schedule.every).toBe('1h')
})
it('sends keywords and min_score when provided', async () => {
mockFetch(201, minimalWatcher)
const client = new ApiClient('im_key')
await client.createWatcher({
name: 'Scored',
kind: 'rss',
config: { url: 'https://example.com/feed.xml' },
schedule: { every: '30m' },
keywords: [{ pattern: 'AI', points: 5 }],
min_score: 3,
})
const [, opts] = (vi.mocked(fetch)).mock.calls[0] as [string, RequestInit]
const body = JSON.parse(opts.body as string) as {
keywords: Array<{ pattern: string; points: number }>
min_score: number
}
expect(body.keywords).toEqual([{ pattern: 'AI', points: 5 }])
expect(body.min_score).toBe(3)
})
it('throws ApiClientError on 400 validation error', async () => {
mockFetch(400, { error: 'Bad Request', issues: [{ message: '"url" is required', path: ['config', 'url'] }] })
const client = new ApiClient('im_key')
await expect(
client.createWatcher({
name: 'Bad',
kind: 'rss',
config: {},
schedule: { every: '1h' },
}),
).rejects.toThrow(ApiClientError)
})
})
describe('ApiClient.updateWatcher', () => {
it('sends PATCH /watchers/:id with status=paused', async () => {
mockFetch(200, { ...minimalWatcher, status: 'paused' })
const client = new ApiClient('im_key')
await client.updateWatcher('wat_1', { status: 'paused' })
const [url, opts] = (vi.mocked(fetch)).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/watchers/wat_1')
expect(opts.method).toBe('PATCH')
const body = JSON.parse(opts.body as string) as { status: string }
expect(body.status).toBe('paused')
})
it('sends PATCH /watchers/:id with status=active', async () => {
mockFetch(200, minimalWatcher)
const client = new ApiClient('im_key')
await client.updateWatcher('wat_1', { status: 'active' })
const [, opts] = (vi.mocked(fetch)).mock.calls[0] as [string, RequestInit]
const body = JSON.parse(opts.body as string) as { status: string }
expect(body.status).toBe('active')
})
it('throws ApiClientError on 404', async () => {
mockFetch(404, { error: 'Not Found' })
const client = new ApiClient('im_key')
await expect(client.updateWatcher('wat_missing', { status: 'paused' })).rejects.toThrow(ApiClientError)
})
})
describe('ApiClient.deleteWatcher', () => {
it('sends DELETE /watchers/:id', async () => {
mockFetch(204, undefined)
const client = new ApiClient('im_key')
await client.deleteWatcher('wat_1')
const [url, opts] = (vi.mocked(fetch)).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/watchers/wat_1')
expect(opts.method).toBe('DELETE')
})
it('resolves to undefined on 204', async () => {
mockFetch(204, undefined)
const client = new ApiClient('im_key')
const result = await client.deleteWatcher('wat_1')
expect(result).toBeUndefined()
})
it('throws ApiClientError on 404', async () => {
mockFetch(404, { error: 'Not Found' })
const client = new ApiClient('im_key')
await expect(client.deleteWatcher('wat_missing')).rejects.toThrow(ApiClientError)
})
})
describe('ApiClient.listWatchers', () => {
it('returns items on success', async () => {
mockFetch(200, { items: [minimalWatcher], has_more: false })
const client = new ApiClient('im_key')
const res = await client.listWatchers()
expect(res.items).toHaveLength(1)
expect(res.items[0].id).toBe('wat_1')
})
it('appends status query param when provided', async () => {
mockFetch(200, { items: [], has_more: false })
const client = new ApiClient('im_key')
await client.listWatchers({ status: 'paused' })
const [url] = (vi.mocked(fetch)).mock.calls[0] as [string, RequestInit]
expect(url).toContain('status=paused')
})
})
// ─── Untrusted payload detection ─────────────────────────────────────────────
describe('isUntrustedPayload', () => {
it('returns true when payload.untrusted is true', () => {
expect(isUntrustedPayload({ untrusted: true })).toBe(true)
})
it('returns false when payload is undefined', () => {
expect(isUntrustedPayload(undefined)).toBe(false)
})
it('returns false when payload is null', () => {
expect(isUntrustedPayload(null)).toBe(false)
})
it('returns false when payload.untrusted is false', () => {
expect(isUntrustedPayload({ untrusted: false })).toBe(false)
})
it('returns false when payload has no untrusted key', () => {
expect(isUntrustedPayload({ kind: 'watcher.triage', score: 42 })).toBe(false)
})
it('returns false when payload.untrusted is a truthy non-boolean', () => {
// Only strict true counts — other truthy values are not enough
expect(isUntrustedPayload({ untrusted: 1 })).toBe(false)
expect(isUntrustedPayload({ untrusted: 'yes' })).toBe(false)
})
it('returns false for primitive payload values', () => {
expect(isUntrustedPayload('untrusted')).toBe(false)
expect(isUntrustedPayload(42)).toBe(false)
})
})