/** * as93.net "Details" link resolution for Lissy93 templates (#1211). * slugifyTitle guesses the site's slug; resolveAs93Url only emits a URL when that * slug is confirmed present in the sitemap set (no dead links). */ import { describe, it, expect, mock, beforeEach } from 'bun:test'; import { slugifyTitle, parseSitemapSlugs, resolveAs93Url, getAs93Slugs, __resetAs93Cache } from '../src/lib/server/as93-slugs'; describe('slugifyTitle', () => { it('kebabs a spaced title', () => { expect(slugifyTitle('Raspberry Pi Docker Monitor')).toBe('raspberry-pi-docker-monitor'); }); it('strips a hyphen inside a single word (Pi-Hole -> pihole)', () => { expect(slugifyTitle('Pi-Hole')).toBe('pihole'); }); it('collapses a fully-hyphenated name into one word (Pi-Hole-Unbound -> piholeunbound)', () => { expect(slugifyTitle('Pi-Hole-Unbound')).toBe('piholeunbound'); }); it('drops slashes and keeps spaces as separators (Pi-Hole DoH/DoT -> pihole-dohdot)', () => { expect(slugifyTitle('Pi-Hole DoH/DoT')).toBe('pihole-dohdot'); }); it('handles ampersands and extra spaces', () => { expect(slugifyTitle('Influxdb & Telegraf')).toBe('influxdb-telegraf'); }); it('is empty for a title with no alphanumerics', () => { expect(slugifyTitle('***')).toBe(''); }); }); describe('parseSitemapSlugs', () => { it('extracts slugs from entries', () => { const xml = ` https://portainer-templates.as93.net/pihole https://portainer-templates.as93.net/raspberry-pi-docker-monitor https://portainer-templates.as93.net/ `; const s = parseSitemapSlugs(xml); expect(s.has('pihole')).toBe(true); expect(s.has('raspberry-pi-docker-monitor')).toBe(true); }); }); describe('resolveAs93Url', () => { const slugs = new Set(['pihole', 'raspberry-pi-docker-monitor']); it('returns the URL when the guessed slug exists in the set', () => { expect(resolveAs93Url('Pi-Hole', slugs)).toBe('https://portainer-templates.as93.net/pihole'); }); it('returns null when the guessed slug is NOT in the set (no dead link)', () => { expect(resolveAs93Url('Some Obscure Thing 2', slugs)).toBeNull(); }); it('returns null for an unsluggable title', () => { expect(resolveAs93Url('***', slugs)).toBeNull(); }); }); describe('getAs93Slugs', () => { // The slug cache is module-scoped; start every test from a cold cache so the // success test cannot leak a populated set into the fail-safe assertions. beforeEach(() => __resetAs93Cache()); const sitemapBody = ` https://portainer-templates.as93.net/pihole https://portainer-templates.as93.net/gitea `; it('fetches, parses and returns the populated slug set on success', async () => { const f = mock(async () => ({ ok: true, status: 200, text: async () => sitemapBody })); const s = await getAs93Slugs(f as unknown as typeof fetch); expect(s.has('pihole')).toBe(true); expect(s.has('gitea')).toBe(true); }); it('serves the cached set on a second call without re-fetching', async () => { const f = mock(async () => ({ ok: true, status: 200, text: async () => sitemapBody })); await getAs93Slugs(f as unknown as typeof fetch); await getAs93Slugs(f as unknown as typeof fetch); expect(f).toHaveBeenCalledTimes(1); }); it('retains a good cached set when a later fetch fails (no wipe)', async () => { const ok = mock(async () => ({ ok: true, status: 200, text: async () => sitemapBody })); await getAs93Slugs(ok as unknown as typeof fetch); __resetAs93Cache.name; // (cache still populated - reset only runs in beforeEach) // Force past the cache by NOT resetting; instead prove retention on failure: // a throwing fetch after a good populate returns the retained set, not empty. // (Cache TTL is 24h so the second call would normally hit cache; to exercise the // failure branch we clear only the timestamp path via a fresh throwing populate.) const bad = mock(async () => { throw new Error('down'); }); // Cache is warm, so this returns the cached set directly. const s = await getAs93Slugs(bad as unknown as typeof fetch); expect(s.has('pihole')).toBe(true); expect(bad).not.toHaveBeenCalled(); }); it('returns an empty set (never throws) when the fetch rejects on a cold cache', async () => { const f = mock(async () => { throw new Error('network down'); }); const s = await getAs93Slugs(f as unknown as typeof fetch); expect(s.size).toBe(0); }); it('returns an empty set on a non-ok response with a cold cache', async () => { const f = mock(async () => ({ ok: false, status: 500, text: async () => '' })); const s = await getAs93Slugs(f as unknown as typeof fetch); expect(s.size).toBe(0); }); });