mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 14:26:42 +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>
389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
/**
|
|
* Discovery route integration tests.
|
|
*
|
|
* Tests CRUD for regions, interests, sources, and the feed endpoint.
|
|
* Uses the same mock-auth pattern as existing mana-events tests.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterAll } from 'bun:test';
|
|
import { buildTestApp, authedRequest, jsonBody, TEST_USER_ID, OTHER_USER_ID } from './helpers';
|
|
|
|
const app = buildTestApp();
|
|
|
|
const BASE = 'http://test/api/v1/discovery';
|
|
|
|
beforeEach(async () => {
|
|
await app.wipe();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.wipe();
|
|
});
|
|
|
|
// ─── Helper ─────────────────────────────────────────────────────────
|
|
|
|
async function createRegion(label = 'Freiburg', lat = 47.997, lon = 7.842, user = TEST_USER_ID) {
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/regions`, {
|
|
method: 'POST',
|
|
body: jsonBody({ label, lat, lon, radiusKm: 25 }),
|
|
user,
|
|
})
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const data = await res.json();
|
|
return data.region;
|
|
}
|
|
|
|
// ─── Regions ────────────────────────────────────────────────────────
|
|
|
|
describe('Discovery Regions', () => {
|
|
it('creates a region', async () => {
|
|
const region = await createRegion();
|
|
expect(region.label).toBe('Freiburg');
|
|
expect(region.lat).toBe(47.997);
|
|
expect(region.lon).toBe(7.842);
|
|
expect(region.radiusKm).toBe(25);
|
|
expect(region.isActive).toBe(true);
|
|
});
|
|
|
|
it('lists only own regions', async () => {
|
|
await createRegion('Freiburg', 47.997, 7.842, TEST_USER_ID);
|
|
await createRegion('Basel', 47.559, 7.589, OTHER_USER_ID);
|
|
|
|
const res = await app.fetch(authedRequest(`${BASE}/regions`));
|
|
const { regions } = await res.json();
|
|
expect(regions).toHaveLength(1);
|
|
expect(regions[0].label).toBe('Freiburg');
|
|
});
|
|
|
|
it('updates a region', async () => {
|
|
const region = await createRegion();
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/regions/${region.id}`, {
|
|
method: 'PUT',
|
|
body: jsonBody({ radiusKm: 50, label: 'Freiburg im Breisgau' }),
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const { region: updated } = await res.json();
|
|
expect(updated.radiusKm).toBe(50);
|
|
expect(updated.label).toBe('Freiburg im Breisgau');
|
|
});
|
|
|
|
it('rejects updating another user region', async () => {
|
|
const region = await createRegion('Basel', 47.559, 7.589, OTHER_USER_ID);
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/regions/${region.id}`, {
|
|
method: 'PUT',
|
|
body: jsonBody({ radiusKm: 100 }),
|
|
})
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('deletes a region (cascades to sources)', async () => {
|
|
const region = await createRegion();
|
|
const delRes = await app.fetch(
|
|
authedRequest(`${BASE}/regions/${region.id}`, { method: 'DELETE' })
|
|
);
|
|
expect(delRes.status).toBe(200);
|
|
|
|
const listRes = await app.fetch(authedRequest(`${BASE}/regions`));
|
|
const { regions } = await listRes.json();
|
|
expect(regions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects invalid coordinates', async () => {
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/regions`, {
|
|
method: 'POST',
|
|
body: jsonBody({ label: 'Bad', lat: 999, lon: 7.0 }),
|
|
})
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ─── Interests ──────────────────────────────────────────────────────
|
|
|
|
describe('Discovery Interests', () => {
|
|
it('creates and lists interests', async () => {
|
|
const res1 = await app.fetch(
|
|
authedRequest(`${BASE}/interests`, {
|
|
method: 'POST',
|
|
body: jsonBody({ category: 'music' }),
|
|
})
|
|
);
|
|
expect(res1.status).toBe(201);
|
|
|
|
const res2 = await app.fetch(
|
|
authedRequest(`${BASE}/interests`, {
|
|
method: 'POST',
|
|
body: jsonBody({ category: 'tech', freetext: 'Rust Meetups', weight: 2.0 }),
|
|
})
|
|
);
|
|
expect(res2.status).toBe(201);
|
|
|
|
const listRes = await app.fetch(authedRequest(`${BASE}/interests`));
|
|
const { interests } = await listRes.json();
|
|
expect(interests).toHaveLength(2);
|
|
expect(interests.find((i: { category: string }) => i.category === 'tech').freetext).toBe(
|
|
'Rust Meetups'
|
|
);
|
|
expect(interests.find((i: { category: string }) => i.category === 'tech').weight).toBe(2.0);
|
|
});
|
|
|
|
it('deletes an interest', async () => {
|
|
const createRes = await app.fetch(
|
|
authedRequest(`${BASE}/interests`, {
|
|
method: 'POST',
|
|
body: jsonBody({ category: 'sport' }),
|
|
})
|
|
);
|
|
const { interest } = await createRes.json();
|
|
|
|
const delRes = await app.fetch(
|
|
authedRequest(`${BASE}/interests/${interest.id}`, { method: 'DELETE' })
|
|
);
|
|
expect(delRes.status).toBe(200);
|
|
|
|
const listRes = await app.fetch(authedRequest(`${BASE}/interests`));
|
|
const { interests } = await listRes.json();
|
|
expect(interests).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ─── Sources ────────────────────────────────────────────────────────
|
|
|
|
describe('Discovery Sources', () => {
|
|
it('creates a source linked to a region', async () => {
|
|
const region = await createRegion();
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://example.com/events.ics',
|
|
name: 'Test Venue',
|
|
regionId: region.id,
|
|
}),
|
|
})
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const { source } = await res.json();
|
|
expect(source.name).toBe('Test Venue');
|
|
expect(source.type).toBe('ical');
|
|
expect(source.regionId).toBe(region.id);
|
|
expect(source.isActive).toBe(true);
|
|
expect(source.errorCount).toBe(0);
|
|
});
|
|
|
|
it('rejects source with invalid region', async () => {
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://example.com/events.ics',
|
|
name: 'Bad',
|
|
regionId: '00000000-0000-0000-0000-000000000000',
|
|
}),
|
|
})
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('rejects source for another user region', async () => {
|
|
const region = await createRegion('Basel', 47.559, 7.589, OTHER_USER_ID);
|
|
const res = await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://example.com/events.ics',
|
|
name: 'Sneaky',
|
|
regionId: region.id,
|
|
}),
|
|
})
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('lists only own sources', async () => {
|
|
const region1 = await createRegion('FR', 47.997, 7.842, TEST_USER_ID);
|
|
const region2 = await createRegion('BS', 47.559, 7.589, OTHER_USER_ID);
|
|
|
|
await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://a.com/cal.ics',
|
|
name: 'A',
|
|
regionId: region1.id,
|
|
}),
|
|
})
|
|
);
|
|
await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://b.com/cal.ics',
|
|
name: 'B',
|
|
regionId: region2.id,
|
|
}),
|
|
user: OTHER_USER_ID,
|
|
})
|
|
);
|
|
|
|
const res = await app.fetch(authedRequest(`${BASE}/sources`));
|
|
const { sources } = await res.json();
|
|
expect(sources).toHaveLength(1);
|
|
expect(sources[0].name).toBe('A');
|
|
});
|
|
|
|
it('deletes a source', async () => {
|
|
const region = await createRegion();
|
|
const createRes = await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://x.com/cal.ics',
|
|
name: 'X',
|
|
regionId: region.id,
|
|
}),
|
|
})
|
|
);
|
|
const { source } = await createRes.json();
|
|
|
|
const delRes = await app.fetch(
|
|
authedRequest(`${BASE}/sources/${source.id}`, { method: 'DELETE' })
|
|
);
|
|
expect(delRes.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─── Feed ───────────────────────────────────────────────────────────
|
|
|
|
describe('Discovery Feed', () => {
|
|
it('returns empty feed when no sources exist', async () => {
|
|
await createRegion();
|
|
const res = await app.fetch(authedRequest(`${BASE}/feed`));
|
|
expect(res.status).toBe(200);
|
|
const { events, hasMore } = await res.json();
|
|
expect(events).toHaveLength(0);
|
|
expect(hasMore).toBe(false);
|
|
});
|
|
|
|
it('records save action', async () => {
|
|
const region = await createRegion();
|
|
|
|
// Insert a discovered event directly to test the action endpoint
|
|
const { sql: rawSql } = await import('drizzle-orm');
|
|
const futureDate = new Date(Date.now() + 7 * 86400000).toISOString();
|
|
|
|
// First create a source so we have a source_id for the FK
|
|
const srcRes = await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://test.com/cal.ics',
|
|
name: 'Test',
|
|
regionId: region.id,
|
|
}),
|
|
})
|
|
);
|
|
const { source } = await srcRes.json();
|
|
|
|
// Insert event directly into DB
|
|
await app.db.execute(
|
|
rawSql`INSERT INTO event_discovery.discovered_events
|
|
(id, source_id, dedupe_hash, title, start_at, source_url, crawled_at)
|
|
VALUES (
|
|
'00000000-0000-0000-0000-000000000001',
|
|
${source.id}::uuid,
|
|
'testhash123',
|
|
'Test Event',
|
|
${futureDate}::timestamptz,
|
|
'https://example.com/event',
|
|
now()
|
|
)`
|
|
);
|
|
|
|
// Record action
|
|
const actionRes = await app.fetch(
|
|
authedRequest(`${BASE}/feed/00000000-0000-0000-0000-000000000001/action`, {
|
|
method: 'POST',
|
|
body: jsonBody({ action: 'save' }),
|
|
})
|
|
);
|
|
expect(actionRes.status).toBe(200);
|
|
|
|
// Verify the action shows in feed
|
|
const feedRes = await app.fetch(authedRequest(`${BASE}/feed`));
|
|
const { events } = await feedRes.json();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].userAction).toBe('save');
|
|
});
|
|
|
|
it('dismiss action + hideDismissed filters events', async () => {
|
|
const region = await createRegion();
|
|
const { sql: rawSql } = await import('drizzle-orm');
|
|
const futureDate = new Date(Date.now() + 7 * 86400000).toISOString();
|
|
|
|
const srcRes = await app.fetch(
|
|
authedRequest(`${BASE}/sources`, {
|
|
method: 'POST',
|
|
body: jsonBody({
|
|
type: 'ical',
|
|
url: 'https://test.com/cal.ics',
|
|
name: 'Test',
|
|
regionId: region.id,
|
|
}),
|
|
})
|
|
);
|
|
const { source } = await srcRes.json();
|
|
|
|
await app.db.execute(
|
|
rawSql`INSERT INTO event_discovery.discovered_events
|
|
(id, source_id, dedupe_hash, title, start_at, source_url, crawled_at)
|
|
VALUES (
|
|
'00000000-0000-0000-0000-000000000002',
|
|
${source.id}::uuid,
|
|
'hash-dismiss',
|
|
'Dismissed Event',
|
|
${futureDate}::timestamptz,
|
|
'https://example.com/ev2',
|
|
now()
|
|
)`
|
|
);
|
|
|
|
// Dismiss
|
|
await app.fetch(
|
|
authedRequest(`${BASE}/feed/00000000-0000-0000-0000-000000000002/action`, {
|
|
method: 'POST',
|
|
body: jsonBody({ action: 'dismiss' }),
|
|
})
|
|
);
|
|
|
|
// Without hideDismissed — shows up
|
|
const res1 = await app.fetch(authedRequest(`${BASE}/feed`));
|
|
const data1 = await res1.json();
|
|
expect(data1.events).toHaveLength(1);
|
|
expect(data1.events[0].userAction).toBe('dismiss');
|
|
|
|
// With hideDismissed — filtered out
|
|
const res2 = await app.fetch(authedRequest(`${BASE}/feed?hideDismissed=true`));
|
|
const data2 = await res2.json();
|
|
expect(data2.events).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects unauthenticated requests', async () => {
|
|
const res = await app.fetch(new Request(`${BASE}/feed`));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|