mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 19:14:39 +02:00
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>
80 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|