Files
wehub-resource-sync d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

271 lines
7.2 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest'
import { SubscriptionsApi, SUBSCRIPTION_DUPLICATE_FEED_ERROR } from '../src/api'
import type { FeedFetcher } from '../src/feed-parser'
import { InMemoryMetaStore } from '../src/store'
import type {
EnqueueItemContext
} from '../src/api'
import type {
ParsedFeed,
SubscriptionCreateInput,
SubscriptionWithItems
} from '../src/types'
import type { SubscriptionsStore } from '../src/store'
class InMemoryStore implements SubscriptionsStore {
private subs = new Map<string, SubscriptionWithItems>()
private nextId = 1
async list() {
return Array.from(this.subs.values()).sort((a, b) => b.updatedAt - a.updatedAt)
}
async get(id: string) {
return this.subs.get(id) ?? null
}
async findDuplicateFeed(feedUrl: string, ignoreId?: string) {
for (const sub of this.subs.values()) {
if (sub.id !== ignoreId && sub.feedUrl === feedUrl) {
return sub.id
}
}
return null
}
async add(input: SubscriptionCreateInput) {
const id = `sub-${this.nextId++}`
const now = Date.now()
const created: SubscriptionWithItems = {
id,
title: input.title ?? input.sourceUrl,
sourceUrl: input.sourceUrl,
feedUrl: input.feedUrl,
platform: input.platform,
keywords: input.keywords ?? [],
tags: input.tags ?? [],
onlyDownloadLatest: input.onlyDownloadLatest ?? true,
enabled: input.enabled ?? true,
status: 'idle',
createdAt: now,
updatedAt: now,
items: []
}
this.subs.set(id, created)
return created
}
async update(id: string, patch: Record<string, unknown>) {
const existing = this.subs.get(id)
if (!existing) return null
const next: SubscriptionWithItems = {
...existing,
...patch,
updatedAt: Date.now()
} as SubscriptionWithItems
this.subs.set(id, next)
return next
}
async remove(id: string) {
return this.subs.delete(id)
}
async replaceItems(
subscriptionId: string,
items: Array<{
id: string
url: string
title: string
publishedAt: number
thumbnail?: string
}>
) {
const sub = this.subs.get(subscriptionId)
if (!sub) return
sub.items = items.map((item) => {
const next: SubscriptionWithItems['items'][number] = {
id: item.id,
url: item.url,
title: item.title,
publishedAt: item.publishedAt,
addedToQueue: false
}
if (item.thumbnail) next.thumbnail = item.thumbnail
return next
})
}
async markItemQueued(subscriptionId: string, itemId: string, taskId: string | null) {
const sub = this.subs.get(subscriptionId)
if (!sub) return
const item = sub.items.find((entry) => entry.id === itemId)
if (!item) return
item.addedToQueue = taskId !== null
if (taskId) {
item.taskId = taskId
} else {
delete item.taskId
}
}
}
class FakeFetcher implements FeedFetcher {
feeds = new Map<string, ParsedFeed>()
calls: string[] = []
async fetch(feedUrl: string) {
this.calls.push(feedUrl)
return (
this.feeds.get(feedUrl) ?? {
title: 'Empty',
items: []
}
)
}
}
describe('SubscriptionsApi', () => {
let store: InMemoryStore
let metaStore: InMemoryMetaStore
let fetcher: FakeFetcher
let enqueued: EnqueueItemContext[]
let api: SubscriptionsApi
beforeEach(() => {
store = new InMemoryStore()
metaStore = new InMemoryMetaStore()
fetcher = new FakeFetcher()
enqueued = []
let counter = 0
api = new SubscriptionsApi({
kind: 'desktop',
pid: 1,
store,
metaStore,
fetcher,
enqueueItem: async (ctx) => {
enqueued.push(ctx)
counter += 1
return `task-${counter}`
},
leader: { scheduleInterval: () => null, clearInterval: () => undefined },
scheduler: {
setTimeoutImpl: () => null,
clearTimeoutImpl: () => undefined
}
})
})
it('add() rejects duplicate feed URLs with SUBSCRIPTION_DUPLICATE_FEED_URL', async () => {
await api.add({
sourceUrl: 'https://x',
feedUrl: 'https://x/feed',
platform: 'custom'
})
await expect(
api.add({
sourceUrl: 'https://x',
feedUrl: 'https://x/feed',
platform: 'custom'
})
).rejects.toThrow(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
})
it('refreshOne() persists feed items and enqueues new ones', async () => {
const created = await api.add({
sourceUrl: 'https://yt',
feedUrl: 'https://yt/feed',
platform: 'youtube',
onlyDownloadLatest: false
})
fetcher.feeds.set('https://yt/feed', {
title: 'YT',
items: [
{
title: 'Latest',
link: 'https://yt/v/1',
guid: 'v1',
isoDate: '2026-04-01T00:00:00Z'
},
{
title: 'Older',
link: 'https://yt/v/2',
guid: 'v2',
isoDate: '2024-01-01T00:00:00Z'
}
]
})
await api.refreshOne(created.id)
const fresh = await api.get({ id: created.id })
expect(fresh.items).toHaveLength(2)
expect(fresh.status).toBe('up-to-date')
expect(enqueued.map((entry) => entry.item.id).sort()).toEqual(['v1', 'v2'])
const newest = fresh.items.find((item) => item.id === 'v1')
expect(newest?.addedToQueue).toBe(true)
expect(newest?.taskId).toBe('task-1')
})
it('refreshOne() marks the subscription failed when fetch throws', async () => {
const created = await api.add({
sourceUrl: 'https://x',
feedUrl: 'https://x/feed',
platform: 'custom'
})
fetcher.fetch = async () => {
throw new Error('boom')
}
await api.refreshOne(created.id)
const fresh = await api.get({ id: created.id })
expect(fresh.status).toBe('failed')
expect(fresh.lastError).toBe('boom')
})
it('itemsQueue() is idempotent for already-queued items', async () => {
const created = await api.add({
sourceUrl: 'https://yt',
feedUrl: 'https://yt/feed',
platform: 'youtube',
onlyDownloadLatest: false
})
fetcher.feeds.set('https://yt/feed', {
title: 'YT',
items: [
{
title: 'Latest',
link: 'https://yt/v/1',
guid: 'v1',
isoDate: '2026-04-01T00:00:00Z'
}
]
})
await api.refreshOne(created.id)
const first = await api.itemsQueue({
subscriptionId: created.id,
itemId: 'v1'
})
expect(first.queued).toBe(false) // already queued by the auto-pass
expect(first.taskId).toBe('task-1')
})
it('start() acquires the lease; only the leader runs scheduled refreshes', async () => {
await api.start()
expect(api.isLeader()).toBe(true)
const otherStore = new InMemoryStore()
const otherEnqueued: EnqueueItemContext[] = []
const other = new SubscriptionsApi({
kind: 'api',
pid: 2,
store: otherStore,
metaStore,
fetcher,
enqueueItem: async (ctx) => {
otherEnqueued.push(ctx)
return 'task-other'
},
leader: { scheduleInterval: () => null, clearInterval: () => undefined },
scheduler: {
setTimeoutImpl: () => null,
clearTimeoutImpl: () => undefined
}
})
await other.start()
expect(other.isLeader()).toBe(false)
})
})