diff --git a/src/db/DittoDB.ts b/src/db/DittoDB.ts index 68fdc627..fbca18d9 100644 --- a/src/db/DittoDB.ts +++ b/src/db/DittoDB.ts @@ -47,7 +47,7 @@ export class DittoDB { provider: new FileMigrationProvider({ fs, path, - migrationFolder: new URL(import.meta.resolve('../db/migrations')).pathname, + migrationFolder: new URL(import.meta.resolve('./migrations')).pathname, }), }); diff --git a/src/test.ts b/src/test.ts index ea9c8fa4..c2dd5b06 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,6 +1,13 @@ -import { NostrEvent } from '@nostrify/nostrify'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { Database as Sqlite } from '@db/sqlite'; +import { NDatabase, NostrEvent } from '@nostrify/nostrify'; +import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite'; +import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; import { finalizeEvent, generateSecretKey } from 'nostr-tools'; +import { DittoTables } from '@/db/DittoTables.ts'; import { purifyEvent } from '@/storages/hydrate.ts'; /** Import an event fixture by name in tests. */ @@ -21,3 +28,31 @@ export function genEvent(t: Partial = {}, sk: Uint8Array = generateS return purifyEvent(event); } + +/** Get an in-memory SQLite database to use for testing. It's automatically destroyed when it goes out of scope. */ +export async function getTestDB() { + const kysely = new Kysely({ + dialect: new DenoSqlite3Dialect({ + database: new Sqlite(':memory:'), + }), + }); + + const migrator = new Migrator({ + db: kysely, + provider: new FileMigrationProvider({ + fs, + path, + migrationFolder: new URL(import.meta.resolve('./db/migrations')).pathname, + }), + }); + + await migrator.migrateToLatest(); + + const store = new NDatabase(kysely); + + return { + store, + kysely, + [Symbol.asyncDispose]: () => kysely.destroy(), + }; +} diff --git a/src/utils/stats.test.ts b/src/utils/stats.test.ts new file mode 100644 index 00000000..2d3eaca9 --- /dev/null +++ b/src/utils/stats.test.ts @@ -0,0 +1,144 @@ +import { assertEquals } from '@std/assert'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; + +import { genEvent, getTestDB } from '@/test.ts'; +import { getAuthorStats, getEventStats, getFollowDiff, updateStats } from '@/utils/stats.ts'; + +Deno.test('updateStats with kind 1 increments notes count', async () => { + await using db = await getTestDB(); + + const sk = generateSecretKey(); + const pubkey = getPublicKey(sk); + + await updateStats({ ...db, event: genEvent({ kind: 1 }, sk) }); + + const stats = await getAuthorStats(db.kysely, pubkey); + + assertEquals(stats!.notes_count, 1); +}); + +Deno.test('updateStats with kind 5 decrements notes count', async () => { + await using db = await getTestDB(); + + const sk = generateSecretKey(); + const pubkey = getPublicKey(sk); + + const create = genEvent({ kind: 1 }, sk); + const remove = genEvent({ kind: 5, tags: [['e', create.id]] }, sk); + + await updateStats({ ...db, event: create }); + assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 1); + await db.store.event(create); + + await updateStats({ ...db, event: remove }); + assertEquals((await getAuthorStats(db.kysely, pubkey))!.notes_count, 0); + await db.store.event(remove); +}); + +Deno.test('updateStats with kind 3 increments followers count', async () => { + await using db = await getTestDB(); + + await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); + await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); + await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); + + const stats = await getAuthorStats(db.kysely, 'alex'); + + assertEquals(stats!.followers_count, 3); +}); + +Deno.test('updateStats with kind 3 decrements followers count', async () => { + await using db = await getTestDB(); + + const sk = generateSecretKey(); + const follow = genEvent({ kind: 3, tags: [['p', 'alex']], created_at: 0 }, sk); + const remove = genEvent({ kind: 3, tags: [], created_at: 1 }, sk); + + await updateStats({ ...db, event: follow }); + assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 1); + await db.store.event(follow); + + await updateStats({ ...db, event: remove }); + assertEquals((await getAuthorStats(db.kysely, 'alex'))!.followers_count, 0); + await db.store.event(remove); +}); + +Deno.test('getFollowDiff returns added and removed followers', () => { + const prev = genEvent({ tags: [['p', 'alex'], ['p', 'bob']] }); + const next = genEvent({ tags: [['p', 'alex'], ['p', 'carol']] }); + + const { added, removed } = getFollowDiff(next.tags, prev.tags); + + assertEquals(added, new Set(['carol'])); + assertEquals(removed, new Set(['bob'])); +}); + +Deno.test('updateStats with kind 6 increments reposts count', async () => { + await using db = await getTestDB(); + + const note = genEvent({ kind: 1 }); + await updateStats({ ...db, event: note }); + await db.store.event(note); + + const repost = genEvent({ kind: 6, tags: [['e', note.id]] }); + await updateStats({ ...db, event: repost }); + await db.store.event(repost); + + const stats = await getEventStats(db.kysely, note.id); + + assertEquals(stats!.reposts_count, 1); +}); + +Deno.test('updateStats with kind 5 decrements reposts count', async () => { + await using db = await getTestDB(); + + const note = genEvent({ kind: 1 }); + await updateStats({ ...db, event: note }); + await db.store.event(note); + + const sk = generateSecretKey(); + const repost = genEvent({ kind: 6, tags: [['e', note.id]] }, sk); + await updateStats({ ...db, event: repost }); + await db.store.event(repost); + + await updateStats({ ...db, event: genEvent({ kind: 5, tags: [['e', repost.id]] }, sk) }); + + const stats = await getEventStats(db.kysely, note.id); + + assertEquals(stats!.reposts_count, 0); +}); + +Deno.test('updateStats with kind 7 increments reactions count', async () => { + await using db = await getTestDB(); + + const note = genEvent({ kind: 1 }); + await updateStats({ ...db, event: note }); + await db.store.event(note); + + const reaction = genEvent({ kind: 7, tags: [['e', note.id]] }); + await updateStats({ ...db, event: reaction }); + await db.store.event(reaction); + + const stats = await getEventStats(db.kysely, note.id); + + assertEquals(stats!.reactions_count, 1); +}); + +Deno.test('updateStats with kind 5 decrements reactions count', async () => { + await using db = await getTestDB(); + + const note = genEvent({ kind: 1 }); + await updateStats({ ...db, event: note }); + await db.store.event(note); + + const sk = generateSecretKey(); + const reaction = genEvent({ kind: 7, tags: [['e', note.id]] }, sk); + await updateStats({ ...db, event: reaction }); + await db.store.event(reaction); + + await updateStats({ ...db, event: genEvent({ kind: 5, tags: [['e', reaction.id]] }, sk) }); + + const stats = await getEventStats(db.kysely, note.id); + + assertEquals(stats!.reactions_count, 0); +}); diff --git a/src/utils/stats.ts b/src/utils/stats.ts index af12a251..306bdabf 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -95,6 +95,18 @@ export function getFollowDiff( }; } +/** Retrieve the author stats by the pubkey. */ +export function getAuthorStats( + kysely: Kysely, + pubkey: string, +): Promise { + return kysely + .selectFrom('author_stats') + .selectAll() + .where('pubkey', '=', pubkey) + .executeTakeFirst(); +} + /** Retrieve the author stats by the pubkey, then call the callback to update it. */ export async function updateAuthorStats( kysely: Kysely, @@ -108,11 +120,7 @@ export async function updateAuthorStats( notes_count: 0, }; - const prev = await kysely - .selectFrom('author_stats') - .selectAll() - .where('pubkey', '=', pubkey) - .executeTakeFirst(); + const prev = await getAuthorStats(kysely, pubkey); const stats = fn(prev ?? empty); @@ -128,6 +136,18 @@ export async function updateAuthorStats( } } +/** Retrieve the event stats by the event ID. */ +export function getEventStats( + kysely: Kysely, + eventId: string, +): Promise { + return kysely + .selectFrom('event_stats') + .selectAll() + .where('event_id', '=', eventId) + .executeTakeFirst(); +} + /** Retrieve the event stats by the event ID, then call the callback to update it. */ export async function updateEventStats( kysely: Kysely, @@ -141,11 +161,7 @@ export async function updateEventStats( reactions_count: 0, }; - const prev = await kysely - .selectFrom('event_stats') - .selectAll() - .where('event_id', '=', eventId) - .executeTakeFirst(); + const prev = await getEventStats(kysely, eventId); const stats = fn(prev ?? empty);