diff --git a/.changeset/nip-50-search.md b/.changeset/nip-50-search.md new file mode 100644 index 00000000..fcf4e998 --- /dev/null +++ b/.changeset/nip-50-search.md @@ -0,0 +1,17 @@ +--- +"nostream": major +--- + +Add NIP-50 full-text search support with PostgreSQL `tsvector`/`GIN` indexing. + +Clients can now include a `search` field in REQ filter objects to perform full-text +queries against event content. Results are ranked by relevance (`ts_rank`) instead +of the usual `created_at` ordering, per the NIP-50 specification. + +Features: +- New `search` filter field accepted in REQ messages +- PostgreSQL GIN index on `to_tsvector('simple', event_content)` for fast full-text lookups +- Configurable text-search language (defaults to `simple`, supports `english`, `spanish`, etc.) +- Configurable max search query length for abuse prevention +- NIP-50 listed in NIP-11 relay information document +- Search can be combined with all existing filter fields (kinds, authors, tags, etc.) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5091ea2f..fe8d9e30 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -179,6 +179,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). | | nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). | | nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. | +| nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | +| nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). | +| nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. | | paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. | | paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) | | paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) | diff --git a/migrations/20260427_000000_add_nip50_fts_index.js b/migrations/20260427_000000_add_nip50_fts_index.js new file mode 100644 index 00000000..468cc102 --- /dev/null +++ b/migrations/20260427_000000_add_nip50_fts_index.js @@ -0,0 +1,11 @@ +exports.config = { transaction: false } + +exports.up = function (knex) { + return knex.raw( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS events_content_fts_idx ON events USING gin (to_tsvector('simple', event_content))", + ) +} + +exports.down = function (knex) { + return knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_content_fts_idx') +} diff --git a/package.json b/package.json index 675394da..d21ec601 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ 33, 40, 44, - 45 + 45, + 50 ], "supportedNipExtensions": [], "main": "src/index.ts", diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 6cdfebb7..85354d64 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -62,6 +62,11 @@ nip05: domainBlacklist: [] nip45: enabled: true +nip50: + enabled: false + # 'simple' (no stemming) or a language name like 'english', 'spanish' + language: simple + maxQueryLength: 256 network: maxPayloadSize: 524288 # Uncomment only when using a trusted reverse proxy and configuring trustedProxies. diff --git a/src/@types/settings.ts b/src/@types/settings.ts index d4463dc9..5af4889b 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -245,6 +245,12 @@ export interface Nip45Settings { enabled?: boolean } +export interface Nip50Settings { + enabled?: boolean + language?: string + maxQueryLength?: number +} + export interface Nip05Settings { mode: Nip05Mode /** @@ -276,4 +282,5 @@ export interface Settings { mirroring?: Mirroring nip05?: Nip05Settings nip45?: Nip45Settings + nip50?: Nip50Settings } diff --git a/src/@types/subscription.ts b/src/@types/subscription.ts index 265506d5..8f548ecf 100644 --- a/src/@types/subscription.ts +++ b/src/@types/subscription.ts @@ -10,5 +10,6 @@ export interface SubscriptionFilter { until?: number authors?: Pubkey[] limit?: number + search?: string [key: `#${string}`]: string[] } diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 5d9fe549..b893cde8 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -19,7 +19,7 @@ const logger = createLogger('worker-factory') export const workerFactory = (): AppWorker => { const dbClient = getMasterDbClient() const readReplicaDbClient = getReadReplicaDbClient() - const eventRepository = new EventRepository(dbClient, readReplicaDbClient) + const eventRepository = new EventRepository(dbClient, readReplicaDbClient, createSettings) const userRepository = new UserRepository(dbClient, eventRepository) const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 19e39fd4..20a5a60a 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -70,6 +70,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N created_at_upper_limit: createdAtLimits?.maxPositiveDelta, default_limit: DEFAULT_FILTER_LIMIT, restricted_writes: hasWriteRestriction, + search_supported: settings.nip50?.enabled ?? false, }, payments_url: paymentsUrl.toString(), fees: Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 6d5c1e1e..5df64aca 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -1,4 +1,4 @@ -import { anyPass, equals, isNil, map, propSatisfies, uniqWith } from 'ramda' +import { anyPass, equals, isNil, map, omit, propSatisfies, uniqWith } from 'ramda' // import { addAbortSignal } from 'stream' import { pipeline } from 'stream/promises' @@ -38,7 +38,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { public async handleMessage(message: SubscribeMessage): Promise { const subscriptionId = message[1] - const filters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[] + const rawFilters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[] + + // NIP-50: strip search from filters when disabled so isEventMatchingFilter ignores it + const nip50Enabled = this.settings()?.nip50?.enabled ?? false + const filters = nip50Enabled ? rawFilters : rawFilters.map(omit(['search'])) as SubscriptionFilter[] const reason = this.canSubscribe(subscriptionId, filters) if (reason) { diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 56da579d..1e1f9903 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -58,10 +58,21 @@ const groupByLengthSpec = groupBy( const logger = createLogger('event-repository') +/** Default text-search configuration when nip50.language is unset. */ +const DEFAULT_TS_CONFIG = 'simple' +/** Maximum search query length when nip50.maxQueryLength is unset. */ +const DEFAULT_MAX_SEARCH_QUERY_LENGTH = 256 + +interface FilterConditionFlags { + isTagQuery: boolean + isSearchQuery: boolean +} + export class EventRepository implements IEventRepository { public constructor( private readonly masterDbClient: DatabaseClient, private readonly readReplicaDbClient: DatabaseClient, + private readonly settings?: () => { nip50?: { enabled?: boolean; language?: string; maxQueryLength?: number } }, ) {} public findByFilters(filters: SubscriptionFilter[]): IQueryResult { @@ -72,15 +83,29 @@ export class EventRepository implements IEventRepository { const queries = filters.map((currentFilter) => { const builder = this.readReplicaDbClient('events') - const isTagQuery = this.applyFilterConditions(builder, currentFilter) - - if (typeof currentFilter.limit === 'number') { + const { isTagQuery, isSearchQuery } = this.applyFilterConditions(builder, currentFilter) + + if (isSearchQuery) { + // NIP-50: sort by relevance (ts_rank) descending, then by event_id for stability + const tsConfig = this.getNip50Language() + const limit = typeof currentFilter.limit === 'number' ? currentFilter.limit : DEFAULT_FILTER_LIMIT + builder + .select( + this.readReplicaDbClient.raw( + `events.*, ts_rank(to_tsvector('${tsConfig}', event_content), plainto_tsquery('${tsConfig}', ?)) AS search_rank`, + [currentFilter.search], + ), + ) + .limit(limit) + .orderBy('search_rank', 'DESC') + .orderBy('event_id', 'asc') + } else if (typeof currentFilter.limit === 'number') { builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') } else { builder.limit(DEFAULT_FILTER_LIMIT).orderBy('event_created_at', 'asc').orderBy('event_id', 'asc') } - if (isTagQuery) { + if (isTagQuery && !isSearchQuery) { builder.select('events.*') } @@ -107,7 +132,7 @@ export class EventRepository implements IEventRepository { const queries = filters.map((currentFilter) => { const builder = this.readReplicaDbClient('events').select('events.event_id') - const isTagQuery = this.applyFilterConditions(builder, currentFilter) + const { isTagQuery } = this.applyFilterConditions(builder, currentFilter) if (typeof currentFilter.limit === 'number') { builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') @@ -134,7 +159,7 @@ export class EventRepository implements IEventRepository { return Number(result?.count ?? 0) } - private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): boolean { + private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags { forEachObjIndexed((tableFields: string[], filterName: string | number) => { builder.andWhere((bd) => { cond([ @@ -179,6 +204,22 @@ export class EventRepository implements IEventRepository { builder.where('event_created_at', '<=', currentFilter.until) } + // NIP-50: full-text search condition + let isSearchQuery = false + if (typeof currentFilter.search === 'string' && currentFilter.search.length > 0) { + const nip50Settings = this.settings?.() + if (nip50Settings?.nip50?.enabled) { + const tsConfig = this.getNip50Language() + const maxLen = nip50Settings.nip50.maxQueryLength ?? DEFAULT_MAX_SEARCH_QUERY_LENGTH + const searchQuery = currentFilter.search.slice(0, maxLen) + builder.andWhereRaw( + `to_tsvector('${tsConfig}', event_content) @@ plainto_tsquery('${tsConfig}', ?)`, + [searchQuery], + ) + isSearchQuery = true + } + } + const andWhereRaw = invoker(1, 'andWhereRaw') const orWhereRaw = invoker(2, 'orWhereRaw') @@ -205,7 +246,12 @@ export class EventRepository implements IEventRepository { builder.leftJoin('event_tags', 'events.event_id', 'event_tags.event_id') } - return isTagQuery + return { isTagQuery, isSearchQuery } + } + + /** Resolve the PostgreSQL text-search configuration name from settings. */ + private getNip50Language(): string { + return this.settings?.()?.nip50?.language ?? DEFAULT_TS_CONFIG } public async create(event: Event): Promise { diff --git a/src/schemas/filter-schema.ts b/src/schemas/filter-schema.ts index 1aa41897..cfa81194 100644 --- a/src/schemas/filter-schema.ts +++ b/src/schemas/filter-schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createdAtSchema, kindSchema, prefixSchema } from './base-schema' import { isGenericTagQuery } from '../utils/filter' -const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit']) +const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit', 'search']) export const filterSchema = z .object({ @@ -13,6 +13,8 @@ export const filterSchema = z since: createdAtSchema.optional(), until: createdAtSchema.optional(), limit: z.number().int().min(0).optional(), + // NIP-50: full-text search query string + search: z.string().min(1).max(1024).optional(), }) .catchall(z.array(z.string().max(1024))) .superRefine((data, ctx) => { diff --git a/src/utils/event.ts b/src/utils/event.ts index 18bad057..9f4e4b1a 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -90,6 +90,15 @@ export const isEventMatchingFilter = return false } + // NIP-50 + if (typeof filter.search === 'string' && filter.search.length > 0) { + const contentLower = event.content.toLowerCase() + const terms = filter.search.toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0 || !terms.every((term) => contentLower.includes(term))) { + return false + } + } + return true } diff --git a/test/integration/features/nip-50/nip-50.feature b/test/integration/features/nip-50/nip-50.feature new file mode 100644 index 00000000..c72932f7 --- /dev/null +++ b/test/integration/features/nip-50/nip-50.feature @@ -0,0 +1,22 @@ +Feature: NIP-50 + Scenario: Alice searches for events by content + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "Bitcoin and Lightning Network are great" + And Bob sends a text_note event with content "Nostr is a decentralized protocol" + And Alice subscribes to search for "bitcoin lightning" + Then Alice receives 1 text_note event from Bob with search match and EOSE + + Scenario: Alice gets no results for a search with no matches + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "Hello world from Nostr" + And Alice subscribes to search for "ethereum solana" + Then Alice receives 0 events for search and EOSE + + Scenario: Alice combines search with kind filter + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "Bitcoin is freedom" + And Alice subscribes to search for "bitcoin" with kinds 1 + Then Alice receives 1 text_note event from Bob with search match and EOSE diff --git a/test/integration/features/nip-50/nip-50.feature.ts b/test/integration/features/nip-50/nip-50.feature.ts new file mode 100644 index 00000000..608bc983 --- /dev/null +++ b/test/integration/features/nip-50/nip-50.feature.ts @@ -0,0 +1,63 @@ +import { Then, When, World } from '@cucumber/cucumber' +import chai from 'chai' +import sinonChai from 'sinon-chai' +import { WebSocket } from 'ws' + +import { + createSubscription, + waitForEOSE, + waitForEventCount, +} from '../helpers' + +chai.use(sinonChai) +const { expect } = chai + +When( + /^(\w+) subscribes to search for "([^"]+)"$/, + async function (this: World>, name: string, searchQuery: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = { name: `test-${Math.random()}`, filters: [{ search: searchQuery }] } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +When( + /^(\w+) subscribes to search for "([^"]+)" with kinds (\d+)$/, + async function (this: World>, name: string, searchQuery: string, kind: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = { + name: `test-${Math.random()}`, + filters: [{ search: searchQuery, kinds: [Number(kind)] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +Then( + /^(\w+) receives (\d+) text_note events? from (\w+) with search match and EOSE$/, + async function (this: World>, name: string, count: string, author: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, Number(count), true) + + expect(events.length).to.equal(Number(count)) + for (const event of events) { + expect(event.kind).to.equal(1) + expect(event.pubkey).to.equal(this.parameters.identities[author].pubkey) + } + }, +) + +Then( + /^(\w+) receives 0 events for search and EOSE$/, + async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + + await waitForEOSE(ws, subscription.name) + }, +) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 71153a20..c38dab68 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -38,6 +38,7 @@ BeforeAll({ timeout: 1000 }, async function () { assocPath(['limits', 'event', 'rateLimits'], []), assocPath(['limits', 'invoice', 'rateLimits'], []), assocPath(['limits', 'connection', 'rateLimits'], []), + assocPath(['nip50', 'enabled'], true), )(settings) as any worker = workerFactory() diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 49d5503d..183d2fdf 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -159,6 +159,25 @@ describe('rootRequestHandler', () => { expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT) }) + it('sets limitation.search_supported to false when NIP-50 is disabled', () => { + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.limitation.search_supported).to.equal(false) + }) + + it('sets limitation.search_supported to true when NIP-50 is enabled', () => { + createSettingsStub.returns({ + ...baseSettings, + nip50: { enabled: true, language: 'simple', maxQueryLength: 256 }, + }) + + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.limitation.search_supported).to.equal(true) + }) + it('sets limitation.restricted_writes based on active write restrictions', () => { rootRequestHandler(req, res, next) const defaultDoc = res.send.firstCall.args[0] diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 6a9ffbfa..a1dd6160 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -427,6 +427,104 @@ describe('EventRepository', () => { ) }) }) + + describe('NIP-50: search', () => { + let searchEnabledRepository: IEventRepository + + beforeEach(() => { + searchEnabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'simple', maxQueryLength: 256 }, + })) + }) + + it('adds tsvector/tsquery WHERE clause when search is provided and enabled', () => { + const filters = [{ search: 'bitcoin lightning' }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include("to_tsvector('simple', event_content) @@ plainto_tsquery('simple', 'bitcoin lightning')") + }) + + it('orders results by search_rank DESC when search is active', () => { + const filters = [{ search: 'nostr relay' }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('search_rank') + expect(query).to.include('"search_rank" DESC') + }) + + it('applies default limit of 500 when search has no explicit limit', () => { + const filters = [{ search: 'test query' }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('limit 500') + }) + + it('applies custom limit when search has explicit limit', () => { + const filters = [{ search: 'test query', limit: 20 }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('limit 20') + }) + + it('combines search with kinds filter', () => { + const filters = [{ search: 'bitcoin', kinds: [1] }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include("plainto_tsquery('simple', 'bitcoin')") + expect(query).to.include('"event_kind" in (1)') + }) + + it('ignores search filter when NIP-50 is disabled', () => { + const disabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: false }, + })) + const filters = [{ search: 'bitcoin' }] + + const query = disabledRepository.findByFilters(filters).toString() + + expect(query).to.not.include('tsvector') + expect(query).to.not.include('tsquery') + expect(query).to.not.include('search_rank') + }) + + it('ignores search filter when no settings are provided', () => { + const noSettingsRepository = new EventRepository(dbClient, rrDbClient) + const filters = [{ search: 'bitcoin' }] + + const query = noSettingsRepository.findByFilters(filters).toString() + + expect(query).to.not.include('tsvector') + expect(query).to.not.include('tsquery') + }) + + it('uses configured language for text search', () => { + const englishRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'english' }, + })) + const filters = [{ search: 'running' }] + + const query = englishRepository.findByFilters(filters).toString() + + expect(query).to.include("to_tsvector('english', event_content)") + expect(query).to.include("plainto_tsquery('english', 'running')") + }) + + it('truncates search query to maxQueryLength', () => { + const shortMaxRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'simple', maxQueryLength: 5 }, + })) + const filters = [{ search: 'bitcoinlightning' }] + + const query = shortMaxRepository.findByFilters(filters).toString() + + expect(query).to.include("plainto_tsquery('simple', 'bitco')") + }) + }) }) describe('.countByFilters', () => { diff --git a/test/unit/schemas/filter-schema.spec.ts b/test/unit/schemas/filter-schema.spec.ts index d6720008..eb34731c 100644 --- a/test/unit/schemas/filter-schema.spec.ts +++ b/test/unit/schemas/filter-schema.spec.ts @@ -140,4 +140,49 @@ describe('NIP-01', () => { }) } }) + + describe('NIP-50: search filter', () => { + it('accepts filter with valid search string', () => { + const filter = { search: 'bitcoin lightning' } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal(filter) + }) + + it('accepts search combined with kinds and limit', () => { + const filter = { search: 'nostr relay', kinds: [1], limit: 20 } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal(filter) + }) + + it('accepts search combined with authors and tags', () => { + const filter = { + search: 'bitcoin', + authors: ['22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793'], + '#e': ['aaaaaa'], + } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal(filter) + }) + + it('rejects empty search string', () => { + const filter = { search: '' } + const result = validateSchema(filterSchema)(filter) + expect(result).to.have.property('error').that.is.not.undefined + }) + + it('rejects search string longer than 1024 characters', () => { + const filter = { search: 'a'.repeat(1025) } + const result = validateSchema(filterSchema)(filter) + expect(result).to.have.property('error').that.is.not.undefined + }) + + it('accepts search string at maximum length of 1024 characters', () => { + const filter = { search: 'a'.repeat(1024) } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + }) + }) }) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index f059f940..fbd23684 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -204,6 +204,59 @@ describe('NIP-01', () => { }) }) + describe('NIP-50: search filter', () => { + let event: Event + + beforeEach(() => { + event = { + id: '6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407', + pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', + created_at: 1648351380, + kind: 1, + tags: [], + content: 'Bitcoin and Lightning Network are revolutionizing payments', + sig: 'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768', + } + }) + + it('returns true if search matches single term in content', () => { + expect(isEventMatchingFilter({ search: 'bitcoin' })(event)).to.be.true + }) + + it('returns true if search matches multiple terms in content', () => { + expect(isEventMatchingFilter({ search: 'bitcoin lightning' })(event)).to.be.true + }) + + it('returns false if search term is not in content', () => { + expect(isEventMatchingFilter({ search: 'ethereum' })(event)).to.be.false + }) + + it('returns false if one of multiple search terms is missing', () => { + expect(isEventMatchingFilter({ search: 'bitcoin ethereum' })(event)).to.be.false + }) + + it('is case-insensitive', () => { + expect(isEventMatchingFilter({ search: 'BITCOIN' })(event)).to.be.true + }) + + it('returns true if search is undefined', () => { + expect(isEventMatchingFilter({})(event)).to.be.true + }) + + it('returns true if search is an empty string', () => { + expect(isEventMatchingFilter({ search: '' })(event)).to.be.true + }) + + it('returns false if search is whitespace-only', () => { + expect(isEventMatchingFilter({ search: ' ' })(event)).to.be.false + }) + + it('combines with other filters', () => { + expect(isEventMatchingFilter({ search: 'bitcoin', kinds: [1] })(event)).to.be.true + expect(isEventMatchingFilter({ search: 'bitcoin', kinds: [2] })(event)).to.be.false + }) + }) + describe('isEventSignatureValid', () => { let event: Event