managarten/services/mana-events/src/__tests__/deduplicator.test.ts
Till JS b5d55fdb21 feat(events): add Event Discovery — Phase 1 + 2
Phase 1: Manual iCal feeds + Discovery tab
- 5 new DB tables in event_discovery schema (regions, interests,
  sources, discovered_events, user_actions)
- iCal parser (node-ical) with deduplication (SHA-256 hash)
- Crawl scheduler (15-min interval, auto-deactivate after 5 errors)
- CRUD routes for regions, interests, sources + paginated feed endpoint
- Frontend: "Meine Events" / "Entdecken" tab navigation in ListView
- Discovery setup wizard (regions via mana-geocoding + interests)
- DiscoveredEventCard with save/dismiss, SourceManager for iCal feeds
- "Merken" creates a local socialEvent from discovered event

Phase 2: Auto source discovery + LLM extraction + relevance scoring
- Source discoverer: web search via mana-research to auto-find iCal
  feeds and venue websites for a region
- Website extractor: crawl via mana-research /extract, then LLM-based
  event extraction via mana-llm with structured JSON output
- Flexible date parsing (ISO, DD.MM.YYYY), markdown fence stripping
- Relevance scorer: category match, freetext match, haversine distance,
  time proximity, weekend bonus (0-100 clamped)
- Routes: POST regions/:id/discover-sources, PUT/DELETE sources/:id/activate|reject
- Frontend: "Automatisch finden" button, suggested vs active sources UI

107 tests (all passing), no regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 15:30:46 +02:00

80 lines
2.8 KiB
TypeScript

/**
* Deduplicator unit tests — no DB required.
*/
import { describe, it, expect } from 'bun:test';
import { computeDedupeHash } from '../discovery/deduplicator';
import type { NormalizedEvent } from '../discovery/types';
function makeEvent(overrides: Partial<NormalizedEvent> = {}): NormalizedEvent {
return {
title: 'Jazz Night',
startAt: new Date('2026-05-01T19:00:00Z'),
sourceUrl: 'https://example.com/event',
location: 'Jazzhaus Freiburg',
...overrides,
};
}
describe('computeDedupeHash', () => {
it('produces a hex string', async () => {
const hash = await computeDedupeHash(makeEvent());
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it('is deterministic (same input = same hash)', async () => {
const a = await computeDedupeHash(makeEvent());
const b = await computeDedupeHash(makeEvent());
expect(a).toBe(b);
});
it('differs when title changes', async () => {
const a = await computeDedupeHash(makeEvent({ title: 'Jazz Night' }));
const b = await computeDedupeHash(makeEvent({ title: 'Rock Night' }));
expect(a).not.toBe(b);
});
it('differs when date changes', async () => {
const a = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-01T19:00:00Z') }));
const b = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-02T19:00:00Z') }));
expect(a).not.toBe(b);
});
it('differs when location changes', async () => {
const a = await computeDedupeHash(makeEvent({ location: 'Jazzhaus Freiburg' }));
const b = await computeDedupeHash(makeEvent({ location: 'E-Werk Freiburg' }));
expect(a).not.toBe(b);
});
it('is case-insensitive (title)', async () => {
const a = await computeDedupeHash(makeEvent({ title: 'Jazz Night' }));
const b = await computeDedupeHash(makeEvent({ title: 'jazz night' }));
expect(a).toBe(b);
});
it('is case-insensitive (location)', async () => {
const a = await computeDedupeHash(makeEvent({ location: 'Jazzhaus Freiburg' }));
const b = await computeDedupeHash(makeEvent({ location: 'jazzhaus freiburg' }));
expect(a).toBe(b);
});
it('treats null and empty location the same', async () => {
const a = await computeDedupeHash(makeEvent({ location: null }));
const b = await computeDedupeHash(makeEvent({ location: '' }));
expect(a).toBe(b);
});
it('ignores time-of-day (same calendar date = same hash)', async () => {
const a = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-01T10:00:00Z') }));
const b = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-01T22:00:00Z') }));
expect(a).toBe(b);
});
it('trims whitespace from title and location', async () => {
const a = await computeDedupeHash(
makeEvent({ title: ' Jazz Night ', location: ' Jazzhaus ' })
);
const b = await computeDedupeHash(makeEvent({ title: 'Jazz Night', location: 'Jazzhaus' }));
expect(a).toBe(b);
});
});