= {}): Listing {
+ return {
+ id: null,
+ platform: 'ebay',
+ platform_listing_id: id,
+ title: `Listing ${id}`,
+ price: 100,
+ currency: 'USD',
+ condition: 'used',
+ seller_platform_id: 'seller1',
+ url: `https://ebay.com/itm/${id}`,
+ photo_urls: [],
+ listing_age_days: 1,
+ buying_format: 'fixed_price',
+ ends_at: null,
+ fetched_at: null,
+ trust_score_id: null,
+ ...overrides,
+ }
+}
+
+function makeTrust(score: number, flags: string[] = []): TrustScore {
+ return {
+ id: null,
+ listing_id: 1,
+ composite_score: score,
+ account_age_score: 20,
+ feedback_count_score: 20,
+ feedback_ratio_score: 20,
+ price_vs_market_score: 20,
+ category_history_score: 20,
+ photo_hash_duplicate: false,
+ photo_analysis_json: null,
+ red_flags_json: JSON.stringify(flags),
+ score_is_partial: false,
+ scored_at: null,
+ }
+}
+
+describe('useSearchStore.getListing', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ sessionStorage.clear()
+ })
+
+ it('returns undefined when results are empty', () => {
+ const store = useSearchStore()
+ expect(store.getListing('abc')).toBeUndefined()
+ })
+
+ it('returns the listing when present in results', () => {
+ const store = useSearchStore()
+ const listing = makeListing('v1|123|0')
+ store.results.push(listing)
+ expect(store.getListing('v1|123|0')).toEqual(listing)
+ })
+
+ it('returns undefined for an id not in results', () => {
+ const store = useSearchStore()
+ store.results.push(makeListing('v1|123|0'))
+ expect(store.getListing('v1|999|0')).toBeUndefined()
+ })
+
+ it('returns the correct listing when multiple are present', () => {
+ const store = useSearchStore()
+ store.results.push(makeListing('v1|001|0', { title: 'First' }))
+ store.results.push(makeListing('v1|002|0', { title: 'Second' }))
+ store.results.push(makeListing('v1|003|0', { title: 'Third' }))
+ expect(store.getListing('v1|002|0')?.title).toBe('Second')
+ })
+
+ it('handles URL-encoded pipe characters in listing IDs', () => {
+ const store = useSearchStore()
+ // The route param arrives decoded from vue-router; store uses decoded string
+ const listing = makeListing('v1|157831011297|0')
+ store.results.push(listing)
+ expect(store.getListing('v1|157831011297|0')).toEqual(listing)
+ })
+})
+
+describe('useSearchStore trust and seller maps', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ sessionStorage.clear()
+ })
+
+ it('trustScores map returns trust by platform_listing_id', () => {
+ const store = useSearchStore()
+ const trust = makeTrust(85, ['low_feedback_count'])
+ store.trustScores.set('v1|123|0', trust)
+ expect(store.trustScores.get('v1|123|0')?.composite_score).toBe(85)
+ })
+
+ it('sellers map returns seller by seller_platform_id', () => {
+ const store = useSearchStore()
+ const seller: Seller = {
+ id: null, platform: 'ebay', platform_seller_id: 'sellerA',
+ username: 'powertech99', account_age_days: 720,
+ feedback_count: 1200, feedback_ratio: 0.998,
+ category_history_json: '{}', fetched_at: null,
+ }
+ store.sellers.set('sellerA', seller)
+ expect(store.sellers.get('sellerA')?.username).toBe('powertech99')
+ })
+})
diff --git a/web/src/__tests__/useTheme.test.ts b/web/src/__tests__/useTheme.test.ts
new file mode 100644
index 0000000..da4b0d6
--- /dev/null
+++ b/web/src/__tests__/useTheme.test.ts
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+// Re-import after each test to get a fresh module-level ref
+// (vi.resetModules() ensures module-level state is cleared between describe blocks)
+
+describe('useTheme', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ delete document.documentElement.dataset.theme
+ })
+
+ it('defaults to system when localStorage is empty', async () => {
+ const { useTheme } = await import('../composables/useTheme')
+ const { mode } = useTheme()
+ expect(mode.value).toBe('system')
+ })
+
+ it('setMode(dark) sets data-theme=dark on html element', async () => {
+ const { useTheme } = await import('../composables/useTheme')
+ const { setMode } = useTheme()
+ setMode('dark')
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ })
+
+ it('setMode(light) sets data-theme=light on html element', async () => {
+ const { useTheme } = await import('../composables/useTheme')
+ const { setMode } = useTheme()
+ setMode('light')
+ expect(document.documentElement.dataset.theme).toBe('light')
+ })
+
+ it('setMode(system) removes data-theme attribute', async () => {
+ const { useTheme } = await import('../composables/useTheme')
+ const { setMode } = useTheme()
+ setMode('dark')
+ setMode('system')
+ expect(document.documentElement.dataset.theme).toBeUndefined()
+ })
+
+ it('setMode persists to localStorage', async () => {
+ const { useTheme } = await import('../composables/useTheme')
+ const { setMode } = useTheme()
+ setMode('dark')
+ expect(localStorage.getItem('snipe:theme')).toBe('dark')
+ })
+
+ it('restore() re-applies dark from localStorage', async () => {
+ localStorage.setItem('snipe:theme', 'dark')
+ // Dynamically import a fresh module to simulate hard reload
+ const { useTheme } = await import('../composables/useTheme')
+ const { restore } = useTheme()
+ restore()
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ })
+
+ it('restore() with system mode leaves data-theme absent', async () => {
+ localStorage.setItem('snipe:theme', 'system')
+ const { useTheme } = await import('../composables/useTheme')
+ const { restore } = useTheme()
+ restore()
+ expect(document.documentElement.dataset.theme).toBeUndefined()
+ })
+})
diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts
index 39b3c02..000a249 100644
--- a/web/src/composables/useTheme.ts
+++ b/web/src/composables/useTheme.ts
@@ -26,7 +26,9 @@ export function useTheme() {
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
function restore() {
- _apply(mode.value)
+ const saved = (localStorage.getItem(LS_KEY) as ThemeMode) ?? 'system'
+ mode.value = saved
+ _apply(saved)
}
return { mode, setMode, restore }
diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue
index 63e48d5..0600faf 100644
--- a/web/src/views/SearchView.vue
+++ b/web/src/views/SearchView.vue
@@ -311,9 +311,9 @@
🎯 Snipe
Bid with confidence.
- Snipe scores eBay listings and sellers for trustworthiness before you place a bid.
- Catches new accounts, suspicious prices, duplicate photos, and known scammers.
- Free. No account required.
+ Seen a listing that looks almost too good to pass up? Snipe tells you if it's safe
+ to bid: seller account age, feedback history, price vs. completed sales, and red flag
+ detection — one trust score before you commit. Free. No account required.
@@ -321,7 +321,7 @@
⚠
Starting May 13, 2026, eBay removes the option for buyers to cancel winning bids.
- Auction sales become final. Know what you're buying before you bid.
+ Auction sales become final. Search above to score listings before you commit.
@@ -330,24 +330,24 @@
🛡
Seller trust score
-
Feedback count and ratio, account age, and category history — scored 0 to 100.
+
Account age, feedback count and ratio, and category history — does this seller actually know what they're selling? Scored 0–100.
📊
Price vs. market
-
Compared against recent completed sales. Flags prices that are suspiciously below market.
+
Checked against recent completed eBay sales. If the price is 40% below median, you'll see it flagged before you bid.
🚩
Red flag detection
-
Duplicate photos, damage mentions, established bad actors, and zero-feedback sellers.
+
Duplicate listing photos, "scratch and dent" buried in the description, zero-feedback sellers, and known bad actors — flagged automatically.
- Free account unlocks saved searches, more results pages, and the community scammer blocklist.
+ Free account unlocks saved searches, up to 5 pages of results, and the community-maintained scammer blocklist.
Create a free account →