Files
website/src/hooks/useSpells.test.ts
highperfocused 1dc89496cf fix: address review feedback on the Spells app
Per review:
- Scope is now derived (forced to "discover" when signed out) rather
  than stored as the requested value directly, matching the Feed
  app's pattern — signing out mid-session can no longer leave "My
  Spells" selected.
- resolveSpellFilter() now validates a spell's tag-filter letter
  (single a-zA-Z char) before using it as a "#<letter>" filter key,
  and clamps limit to [1, 500] instead of trusting a relay-sourced
  spell's number outright — a malformed or hostile spell can no
  longer produce a "#undefined" filter key or an enormous/NaN/zero
  limit. Added regression tests for all of these.
- NewSpellForm's Field now renders a real <label htmlFor> connected
  to each input's id (via useId()), and the Authors button group
  moved to a <fieldset>/<legend> instead of a label sitting over
  unrelated buttons — screen readers can now name every control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
2026-09-06 18:09:41 +02:00

117 lines
4.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { encodeSpellTags, parseSpell, resolveSpellFilter, resolveTimestamp, type ParsedSpell } from './useSpells';
function spellEvent(tags: string[][], content = ''): NostrEvent {
return { id: 'x', pubkey: 'author', created_at: 0, kind: 777, tags, content, sig: '' };
}
/** A ParsedSpell as if it came straight off a relay — encodeSpellTags always produces well-formed data, so malformed cases are built by hand. */
function parsedSpell(overrides: Partial<ParsedSpell>): ParsedSpell {
return {
description: '',
kinds: [1],
authors: [],
topics: [],
event: spellEvent([]),
...overrides,
};
}
describe('resolveTimestamp', () => {
it('resolves a relative duration against now', () => {
const now = Math.floor(Date.now() / 1000);
expect(resolveTimestamp('7d')).toBeCloseTo(now - 7 * 86400, -1);
});
it('resolves "now"', () => {
const now = Math.floor(Date.now() / 1000);
expect(resolveTimestamp('now')).toBeCloseTo(now, -1);
});
it('resolves an absolute unix timestamp', () => {
expect(resolveTimestamp('1700000000')).toBe(1700000000);
});
it('is undefined for blank input', () => {
expect(resolveTimestamp('')).toBeUndefined();
expect(resolveTimestamp(' ')).toBeUndefined();
});
});
describe('encodeSpellTags / parseSpell round-trip', () => {
it('round-trips a spell with authors, a tag filter, since and topics', () => {
const tags = encodeSpellTags({
name: 'Bitcoin from contacts',
description: 'Notes about Bitcoin from my contacts',
kinds: [1],
authors: ['$contacts'],
tagFilter: { letter: 't', values: ['bitcoin'] },
since: '7d',
limit: 50,
topics: ['bitcoin', 'social'],
});
const event = spellEvent(tags, 'Notes about Bitcoin from my contacts');
const parsed = parseSpell(event);
expect(parsed.name).toBe('Bitcoin from contacts');
expect(parsed.kinds).toEqual([1]);
expect(parsed.authors).toEqual(['$contacts']);
expect(parsed.tagFilter).toEqual({ letter: 't', values: ['bitcoin'] });
expect(parsed.since).toBe('7d');
expect(parsed.limit).toBe(50);
expect(parsed.topics).toEqual(['bitcoin', 'social']);
});
});
describe('resolveSpellFilter', () => {
it('resolves $me and $contacts into real pubkeys', () => {
const event = spellEvent(encodeSpellTags({ kinds: [1], authors: ['$me', '$contacts'] }));
const filter = resolveSpellFilter(parseSpell(event), { me: 'abc', contacts: ['def', 'ghi'] });
expect(filter?.authors).toEqual(['abc', 'def', 'ghi']);
});
it('returns null when $me is required but nobody is signed in', () => {
const event = spellEvent(encodeSpellTags({ kinds: [1], authors: ['$me'] }));
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter).toBeNull();
});
it('returns null when there are no kinds', () => {
const event = spellEvent(encodeSpellTags({ kinds: [] }));
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter).toBeNull();
});
it('turns a tag filter into a #<letter> filter field', () => {
const event = spellEvent(
encodeSpellTags({ kinds: [1], tagFilter: { letter: 't', values: ['bitcoin', 'nostr'] } }),
);
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter?.['#t']).toEqual(['bitcoin', 'nostr']);
});
it('ignores a malformed (non-single-letter) tag filter instead of producing "#undefined"', () => {
const spell = parsedSpell({ tagFilter: { letter: '', values: ['x'] } });
const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
expect(Object.keys(filter ?? {}).some((key) => key.startsWith('#'))).toBe(false);
});
it('clamps an excessive limit to the maximum', () => {
const spell = parsedSpell({ limit: 1_000_000 });
const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
expect(filter?.limit).toBe(500);
});
it('drops a zero or NaN limit rather than sending a degenerate query', () => {
expect(resolveSpellFilter(parsedSpell({ limit: 0 }), { me: undefined, contacts: [] })?.limit).toBeUndefined();
expect(resolveSpellFilter(parsedSpell({ limit: NaN }), { me: undefined, contacts: [] })?.limit).toBeUndefined();
});
it('keeps a normal, in-range limit as-is', () => {
const spell = parsedSpell({ limit: 50 });
const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
expect(filter?.limit).toBe(50);
});
});