diff --git a/.changeset/nip-50-search.md b/.changeset/nip-50-search.md new file mode 100644 index 00000000..57d54ef7 --- /dev/null +++ b/.changeset/nip-50-search.md @@ -0,0 +1,17 @@ +--- +"nostream": minor +--- + +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..473edb9b 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). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | +| 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 7577f352..afae0fe4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ 40, 44, 45, + 50, 65 ], "supportedNipExtensions": [], diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 1f8948a5..138b1288 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 wot: # Web of Trust filtering. When enabled, only events from pubkeys within # the relay owner's 2-hop follow graph are accepted. diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 348efdae..db2e060e 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 /** @@ -295,5 +301,6 @@ export interface Settings { mirroring?: Mirroring nip05?: Nip05Settings nip45?: Nip45Settings + nip50?: Nip50Settings wot?: WoTSettings } 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 d901b934..f322057f 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -102,6 +102,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 970af008..e9fac71a 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 597e574c..10e250a4 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -12,6 +12,7 @@ import { prop, propSatisfies, } from 'ramda' +import { Settings } from '../@types/settings' import { ContextMetadataKey, @@ -38,10 +39,21 @@ type HexCriterionGroups = { odd: string[] } +/** 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?: () => Settings, ) {} public findByFilters(filters: SubscriptionFilter[]): IQueryResult { @@ -52,15 +64,32 @@ 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 nip50Settings = this.settings?.() + const maxLen = nip50Settings?.nip50?.maxQueryLength ?? DEFAULT_MAX_SEARCH_QUERY_LENGTH + const searchQuery = currentFilter.search.trim().slice(0, maxLen) + const limit = typeof currentFilter.limit === 'number' ? currentFilter.limit : DEFAULT_FILTER_LIMIT + builder + .select( + this.readReplicaDbClient.raw( + 'events.*, ts_rank(to_tsvector(?::regconfig, event_content), plainto_tsquery(?::regconfig, ?)) AS search_rank', + [tsConfig, tsConfig, searchQuery], + ), + ) + .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.*') } @@ -87,7 +116,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') @@ -114,7 +143,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 { this.applyHexFilterConditions(builder, currentFilter) if (Array.isArray(currentFilter.kinds)) { @@ -129,13 +158,34 @@ 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.trim().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.trim().slice(0, maxLen) + builder.andWhereRaw( + 'to_tsvector(?::regconfig, event_content) @@ plainto_tsquery(?::regconfig, ?)', + [tsConfig, tsConfig, searchQuery], + ) + isSearchQuery = true + } + } + const isTagQuery = this.applyGenericTagFilterConditions(builder, currentFilter) if (isTagQuery) { 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 } private applyHexFilterConditions(builder: any, currentFilter: SubscriptionFilter): void { diff --git a/src/schemas/filter-schema.ts b/src/schemas/filter-schema.ts index e64c9f55..b2fbbb3c 100644 --- a/src/schemas/filter-schema.ts +++ b/src/schemas/filter-schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createdAtSchema, geohashFilterValueSchema, kindSchema, prefixSchema } from './base-schema' import { isGenericTagQuery, isGeohashTagQuery } 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 b49f4bcb..78b012b0 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -102,6 +102,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/docker-compose.yml b/test/integration/docker-compose.yml index 790a87b4..9baea88a 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -27,6 +27,7 @@ services: - ../../.nycrc.json:/code/.nycrc.json - ../../.coverage:/code/.coverage - ../../.test-reports:/code/.test-reports + - ../../test/integration/settings.yaml:/code/settings.yaml - ../../tsconfig.json:/code/tsconfig.json working_dir: /code depends_on: 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/integration/settings.yaml b/test/integration/settings.yaml new file mode 100644 index 00000000..469f5d61 --- /dev/null +++ b/test/integration/settings.yaml @@ -0,0 +1,7 @@ +# Integration test settings override. +# Enables features that are disabled by default so integration tests can +# exercise them. +nip50: + enabled: true + language: simple + maxQueryLength: 256 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 e046b3e4..a72bec27 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -192,6 +192,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 04793d42..52de1f10 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -449,6 +449,104 @@ describe('EventRepository', () => { ) }) }) + + describe('NIP-50: search', () => { + let searchEnabledRepository: IEventRepository + + beforeEach(() => { + searchEnabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'simple', maxQueryLength: 256 }, + }) as any) + }) + + 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'::regconfig, event_content) @@ plainto_tsquery('simple'::regconfig, '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'::regconfig, '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 }, + }) as any) + 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' }, + }) as any) + const filters = [{ search: 'running' }] + + const query = englishRepository.findByFilters(filters).toString() + + expect(query).to.include("to_tsvector('english'::regconfig, event_content)") + expect(query).to.include("plainto_tsquery('english'::regconfig, 'running')") + }) + + it('truncates search query to maxQueryLength', () => { + const shortMaxRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'simple', maxQueryLength: 5 }, + }) as any) + const filters = [{ search: 'bitcoinlightning' }] + + const query = shortMaxRepository.findByFilters(filters).toString() + + expect(query).to.include("plainto_tsquery('simple'::regconfig, 'bitco')") + }) + }) }) describe('.countByFilters', () => { diff --git a/test/unit/schemas/filter-schema.spec.ts b/test/unit/schemas/filter-schema.spec.ts index e45a7899..b6fee41a 100644 --- a/test/unit/schemas/filter-schema.spec.ts +++ b/test/unit/schemas/filter-schema.spec.ts @@ -140,6 +140,51 @@ 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 + }) + }) }) describe('NIP-12', () => { diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index e91fe09b..53cc0f6c 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -207,6 +207,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