diff --git a/assets/components.css b/assets/components.css index 9f2d94a..f30db7e 100644 --- a/assets/components.css +++ b/assets/components.css @@ -156,6 +156,37 @@ } } +/* Like .input-icon, but the icon is a clickable button at the end of the + input rather than a decorative marker at the start. */ +.input-icon-action { + position: relative; + display: flex; + align-items: center; + + & input { + inline-size: 100%; + padding-inline-end: var(--space-6); + } + + & button { + all: unset; + position: absolute; + inset-inline-end: var(--space-2); + display: flex; + cursor: pointer; + color: oklch(from var(--grey) l c h / 0.5); + + &:hover { + color: var(--grey); + } + + & svg { + inline-size: var(--space-4); + block-size: var(--space-4); + } + } +} + /* A fieldset of radio buttons, one .radio-item per option. */ .radio-group { & .radio-item { @@ -290,11 +321,18 @@ gap: var(--space-3); } + /* .input-icon-action reserves the room for the icon, but the rule below + is more specific than its own, so it has to say so here. */ + & .input-icon-action input[type="search"] { + padding-inline-end: var(--space-6); + } + & input[type="search"] { - padding: var(--space-1) var(--space-3); - border: 0; - border-radius: var(--space-1); - background: oklch(96% 0 0); + padding: 0 var(--space-2); + border: 1px solid var(--grey); + border-radius: 0; + background: oklch(90% 0 0 / 0.4); + box-shadow: inset 0 1px 3px oklch(0% 0 0 / 0.3); color: var(--grey); font-size: var(--text-sm); @@ -492,3 +530,96 @@ backdrop-filter: blur(4px); } } + +.search-page { + margin: var(--space-6) auto; + inline-size: 100%; + max-inline-size: 72rem; + padding-inline: var(--space-4); +} + +/* Both grids fill as many columns as fit, landing on 4 at the page's own + max width, and fewer as the viewport narrows. */ +.search-users { + list-style-type: none; + margin: 0 0 var(--space-5); + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: var(--space-4); + + & a { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + color: inherit; + text-decoration: none; + text-align: center; + + &:hover .search-avatar { + background: oklch(85% 0 0 / 0.5); + } + } +} + +.search-avatar { + inline-size: 4.5rem; + block-size: 4.5rem; + border-radius: 50%; + background: oklch(90% 0 0 / 0.4); + display: flex; + align-items: center; + justify-content: center; + color: oklch(from var(--grey) l c h / 0.5); + + & svg { + inline-size: 2rem; + block-size: 2rem; + } +} + +.search-results { + list-style-type: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--space-5); +} + +.search-result { + & a { + display: block; + color: inherit; + text-decoration: none; + } + + & h2 { + margin: var(--space-2) 0 0; + font-size: var(--text-sm); + } + + & > p.hint { + margin-block-start: var(--space-1); + text-wrap: pretty; + } +} + +.search-thumb { + aspect-ratio: 16 / 9; + border-radius: var(--space-1); + background: oklch(90% 0 0 / 0.4); +} + +.search-result-description { + margin: var(--space-1) 0 0; + text-wrap: pretty; + color: var(--grey); + + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/migrations/20260826120000_mod_search.sql b/migrations/20260826120000_mod_search.sql new file mode 100644 index 0000000..524487b --- /dev/null +++ b/migrations/20260826120000_mod_search.sql @@ -0,0 +1,26 @@ +-- Full-text index over mod listings. + +-- SQLite's own FTS5 rather than a search crate: the rows being searched are +-- already in this database, so indexing happens in the same write that +-- mirrors a record and there is no second store to rebuild after a backfill. +-- +-- Porter stemming so "mods" finds "mod". `author_did`/`rkey` are carried +-- along unindexed, to look the listing back up with - `mod_listing` is keyed +-- on the pair, so there is no integer rowid to join on. +CREATE VIRTUAL TABLE IF NOT EXISTS mod_search USING fts5 +( + title, + description, + details, + game, + platform, + category, + tags, + author, + + author_did UNINDEXED, + rkey UNINDEXED, + slug UNINDEXED, + + tokenize = 'porter unicode61' +); diff --git a/src/atproto/actor.rs b/src/atproto/actor.rs index 1a25375..80906e6 100644 --- a/src/atproto/actor.rs +++ b/src/atproto/actor.rs @@ -66,6 +66,8 @@ async fn dev_resolve_document_by_did(state: &AppState, did: &str) -> Option Result<()> { + let previous = did.cached_handle(db).await?; + sqlx::query( "INSERT INTO actors (did, handle, resolved_at) VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) @@ -78,6 +80,12 @@ pub async fn upsert(db: &SqlitePool, did: &Did, handle: &Handle) -> Result<()> { .execute(db) .await?; + // The search index carries the handle rather than joining for it, so a + // rename has to rewrite the rows that spell out the old one. + if previous.as_ref() != Some(handle) { + crate::search::reindex_author(db, did).await?; + } + Ok(()) } @@ -104,7 +112,7 @@ impl Did { } /// The handle last seen for this DID, without resolving. - async fn cached_handle(&self, db: &SqlitePool) -> Result> { + pub async fn cached_handle(&self, db: &SqlitePool) -> Result> { let row = sqlx::query("SELECT handle FROM actors WHERE did = ?") .bind(self.as_str()) .fetch_optional(db) diff --git a/src/atproto/lexicon/mod_listing.rs b/src/atproto/lexicon/mod_listing.rs index 16a355a..038b242 100644 --- a/src/atproto/lexicon/mod_listing.rs +++ b/src/atproto/lexicon/mod_listing.rs @@ -337,6 +337,8 @@ impl Record for ModListing { .execute(db) .await?; + crate::search::index(db, did, rkey, self).await?; + Ok(()) } @@ -358,6 +360,8 @@ impl Record for ModListing { .execute(db) .await?; + crate::search::remove(db, did, rkey).await?; + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 29ff1f3..924861a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod mods; mod oauth; mod page; mod s3; +mod search; mod secrets; mod state; @@ -30,6 +31,7 @@ async fn main() { .merge(oauth::router()) .merge(assets::router()) .merge(mods::router()) + .merge(search::router()) .route("/", get(handle_get_index)) .with_state(state); diff --git a/src/page.rs b/src/page.rs index 4dcc076..9849ba3 100644 --- a/src/page.rs +++ b/src/page.rs @@ -23,6 +23,16 @@ pub fn icon(icon: icondata::Icon) -> Markup { } } +/// The value of `name` in a raw (`application/x-www-form-urlencoded`) query +/// string, if present. +fn query_param(query: &str, name: &str) -> Option { + query + .split('&') + .find_map(|pair| pair.strip_prefix(name)?.strip_prefix('=')) + .map(|value| value.replace('+', " ")) + .map(|value| urlencoding::decode(&value).unwrap_or_default().into_owned()) +} + pub struct Page { body: Markup, title: Option, @@ -31,6 +41,13 @@ pub struct Page { /// The request path's first segment, e.g. "forum" for "/forum/123" -- /// used to highlight the matching nav tab. tab: String, + /// The `q` param, when the request is for `/search` -- prefills the nav + /// search box so it reflects what's actually being searched for. + search_query: Option, + /// The `type` param, when the request is for `/search` -- carried along + /// as a hidden field so a new search from the nav box keeps the current + /// result type instead of resetting it to "all". + search_type: Option, /// Whether htmx made this request, in which case it is swapping the body /// into a document that already has the nav, styles and scripts. is_htmx: bool, @@ -105,8 +122,18 @@ impl Page { a.accent-yellow.tab-active[tab == "forum"] href="/forum" { "Forum" } } div.top-menu-actions { - form method="get" action="/search" { - input type="search" name="q" placeholder="Search..." aria-label="Search"; + form method="get" action="/search" + hx-get="/search" hx-target="#page-content" hx-swap="innerHTML" hx-push-url="true" { + @if let Some(search_type) = &self.search_type { + input type="hidden" name="type" value=(search_type); + } + div.input-icon-action { + input type="search" name="q" placeholder="Search" aria-label="Search" + value=[self.search_query.as_deref()]; + button type="submit" aria-label="Submit search" { + (icon(icondata::LuSearch)) + } + } } @match &self.identity { Some(identity) => { @@ -135,7 +162,7 @@ impl Page { } } } - (self.body) + div id="page-content" { (self.body) } } } } @@ -161,12 +188,18 @@ impl FromRequestParts for Page { .unwrap_or("") .to_string(); + let query = (tab == "search").then(|| parts.uri.query()).flatten(); + let search_query = query.and_then(|query| query_param(query, "q")); + let search_type = query.and_then(|query| query_param(query, "type")); + Ok(Self { body: PreEscaped(String::new()), title: None, has_nav: true, identity, tab, + search_query, + search_type, is_htmx: parts.headers.contains_key("hx-request"), }) } diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..3342641 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,541 @@ +//! `/search` and the full-text index behind it. +//! +//! The index is derived from `mod_listing`. It is rebuilt at startup and +//! maintained by [`index`] and [`remove`], which the lexicon calls whenever +//! it mirrors or drops a record. + +use anyhow::Result; +use axum::extract::{Query, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use maud::{html, Markup}; +use serde::Deserialize; +use sqlx::{Row, SqlitePool}; + +use crate::atproto::id::{Did, Handle, Rkey}; +use crate::atproto::lexicon::mod_listing::Game; +use crate::atproto::lexicon::ModListing; +use crate::error::AppError; +use crate::page::{icon, Page}; +use crate::state::AppState; + +/// How many results one page of search shows. +const LIMIT: i64 = 50; + +/// How many users show in the "All" tab's row, above the mod results. +const USER_ROW_LIMIT: i64 = 6; + +pub fn router() -> Router { + Router::new().route("/search", get(handle_get_search)) +} + +/// Add or replace a listing's row in the index. +pub async fn index(db: &SqlitePool, did: &Did, rkey: &Rkey, listing: &ModListing) -> Result<()> { + remove(db, did, rkey).await?; + + // The author's handle is what people search by, so it goes in the row + // rather than being joined at query time. [`reindex_author`] rewrites + // these when a handle changes. + let author = did.cached_handle(db).await?; + + // Both the code and the full name, so "ss", "sticker" and "Paper Mario" + // all reach Sticker Star. + let game = format!("{} {}", listing.game.as_str(), listing.game); + let tags = listing.tags.join(" "); + + sqlx::query( + "INSERT INTO mod_search + (title, description, details, game, platform, category, tags, + author, author_did, rkey, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&listing.title) + .bind(&listing.description) + .bind(&listing.details) + .bind(game) + .bind(listing.platform().as_str()) + .bind(&listing.category) + .bind(tags) + .bind(author.map(|h| h.as_str().to_string()).unwrap_or_default()) + .bind(did.as_str()) + .bind(rkey.as_str()) + .bind(&listing.slug) + .execute(db) + .await?; + + Ok(()) +} + +/// Drop a listing's row from the index. +pub async fn remove(db: &SqlitePool, did: &Did, rkey: &Rkey) -> Result<()> { + sqlx::query("DELETE FROM mod_search WHERE author_did = ? AND rkey = ?") + .bind(did.as_str()) + .bind(rkey.as_str()) + .execute(db) + .await?; + + Ok(()) +} + +/// Rewrite every row belonging to an author, after their handle changed. +pub async fn reindex_author(db: &SqlitePool, did: &Did) -> Result<()> { + for (rkey, listing) in ModListing::find_by_author(db, did).await? { + index(db, did, &rkey, &listing).await?; + } + + Ok(()) +} + +/// Rebuild the whole index from `mod_listing`. +/// +/// Call at startup: listings mirrored before the index existed are only +/// reachable once this has run. +pub async fn reindex_all(db: &SqlitePool) -> Result<()> { + sqlx::query("DELETE FROM mod_search").execute(db).await?; + + let dids: Vec = sqlx::query_scalar("SELECT DISTINCT author_did FROM mod_listing") + .fetch_all(db) + .await?; + for did in dids { + reindex_author(db, &did).await?; + } + + Ok(()) +} + +/// One hit, with enough to render it without going back to `mod_listing`. +pub struct Hit { + pub slug: String, + pub handle: Option, + pub title: String, + pub description: String, + pub game: Option, +} + +/// Turn what someone typed into an FTS5 query, or None if it was blank. +fn to_fts_query(input: &str) -> Option { + let terms: Vec = input + .split_whitespace() + .map(|word| word.replace('"', "")) + .filter(|word| !word.is_empty()) + .map(|word| format!("\"{word}\"")) + .collect(); + + (!terms.is_empty()).then(|| terms.join(" ")) +} + +/// Mods matching `input`, best first, capped at [`LIMIT`]. +pub async fn search(db: &SqlitePool, input: &str) -> Result> { + let Some(query) = to_fts_query(input) else { + return Ok(vec![]); + }; + + let rows = sqlx::query( + "SELECT author_did, rkey, slug, title, description, game + FROM mod_search + WHERE mod_search MATCH ? + ORDER BY bm25(mod_search, 10.0, 3.0, 1.0, 5.0, 5.0, 3.0, 3.0, 8.0) + LIMIT ?", + ) + .bind(query) + .bind(LIMIT) + .fetch_all(db) + .await?; + + let mut hits = Vec::with_capacity(rows.len()); + for row in rows { + let did: Did = row.get("author_did"); + let handle = did.cached_handle(db).await?; + // The indexed form is " ", and only the code parses. + let game: String = row.get("game"); + let game = game.split_whitespace().next().and_then(|c| c.parse().ok()); + + hits.push(Hit { + slug: row.get("slug"), + handle, + title: row.get("title"), + description: row.get("description"), + game, + }); + } + + Ok(hits) +} + +/// Users whose handle contains `input`, capped at `limit`. +pub async fn search_users(db: &SqlitePool, input: &str, limit: i64) -> Result> { + if input.is_empty() { + return Ok(vec![]); + } + + // `%`/`_` are LIKE wildcards, so a handle containing either has to have + // them escaped or it would match more than it should. + let pattern = format!( + "%{}%", + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") + ); + + // A handle can outlive the DID that first claimed it: a later claim adds + // a new row rather than replacing the old one (actors is keyed on DID), + // so without this only the newest claim per handle would be picked -- + // the same tie-break `Handle::cached_did` uses to resolve handle -> DID. + let rows = sqlx::query( + "SELECT handle FROM ( + SELECT handle, ROW_NUMBER() OVER ( + PARTITION BY handle ORDER BY resolved_at DESC, rowid DESC + ) AS rank + FROM actors WHERE handle LIKE ? ESCAPE '\\' + ) + WHERE rank = 1 + ORDER BY length(handle), handle + LIMIT ?", + ) + .bind(pattern) + .bind(limit) + .fetch_all(db) + .await?; + + Ok(rows.into_iter().map(|row| row.get("handle")).collect()) +} + +/// Which kind of thing a search results page shows -- the tab strip above +/// the results, mirroring YouTube's own. Not to be confused with a filter +/// (e.g. narrowing mods to one game), which narrows within a type rather +/// than picking what's shown at all. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ResultType { + All, + Mods, + Users, +} + +impl ResultType { + const ALL: [Self; 3] = [Self::All, Self::Mods, Self::Users]; + + fn from_query_value(value: &str) -> Self { + match value { + "mods" => Self::Mods, + "users" => Self::Users, + _ => Self::All, + } + } + + fn as_query_value(self) -> &'static str { + match self { + Self::All => "all", + Self::Mods => "mods", + Self::Users => "users", + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Mods => "Mods", + Self::Users => "Users", + } + } +} + +#[derive(Deserialize)] +struct SearchQuery { + #[serde(default)] + q: String, + #[serde(default, rename = "type")] + r#type: String, +} + +async fn handle_get_search( + State(state): State, + Query(query): Query, + page: Page, +) -> Result, AppError> { + let input = query.q.trim(); + let result_type = ResultType::from_query_value(&query.r#type); + + let users = match result_type { + ResultType::Mods => vec![], + ResultType::All => search_users(&state.db, input, USER_ROW_LIMIT) + .await + .map_err(AppError::Internal)?, + ResultType::Users => search_users(&state.db, input, LIMIT) + .await + .map_err(AppError::Internal)?, + }; + let hits = match result_type { + ResultType::Users => vec![], + ResultType::All | ResultType::Mods => { + search(&state.db, input).await.map_err(AppError::Internal)? + } + }; + + Ok(page + .body(results(input, result_type, &users, &hits)) + .title(format!("Search results for '{input}'")) + .render()) +} + +fn results(input: &str, result_type: ResultType, users: &[Handle], hits: &[Hit]) -> Markup { + let query = urlencoding::encode(input); + + html! { + main.search-page { + nav.mod-tabs aria-label="Search result types" { + @for tab in ResultType::ALL { + a href={ "/search?q=" (query) "&type=" (tab.as_query_value()) } + hx-get={ "/search?q=" (query) "&type=" (tab.as_query_value()) } + hx-target="#page-content" hx-swap="innerHTML" hx-push-url="true" + .mod-tab-active[tab == result_type] { + (tab.label()) + } + } + } + @if input.is_empty() { + } @else if users.is_empty() && hits.is_empty() { + p { "No search results :(" } + } @else { + @if !users.is_empty() { + ul.search-users { + @for handle in users { + li { + a href={ "/" (handle) } { + div.search-avatar { (icon(icondata::LuUser)) } + span { (handle) } + } + } + } + } + } + @if !hits.is_empty() { + ul.search-results { + @for hit in hits { + li.search-result { + @match &hit.handle { + Some(handle) => { + a href={ "/" (handle) "/" (hit.slug) } { + div.search-thumb {} + h2 { (hit.title) } + @if !hit.description.is_empty() { + p.search-result-description { (hit.description) } + } + } + } + // Nothing links to a listing whose author has + // no known handle, since the URL needs one. + None => { + div.search-thumb {} + h2 { (hit.title) } + @if !hit.description.is_empty() { + p.search-result-description { (hit.description) } + } + } + } + } + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atproto::actor; + use crate::atproto::lexicon::Record; + + #[test] + fn punctuation_fts5_would_read_as_syntax_is_defused() { + assert_eq!(to_fts_query("paper-mario"), Some(r#""paper-mario""#.into())); + assert_eq!(to_fts_query(r#"a "b" c"#), Some(r#""a" "b" "c""#.into())); + assert_eq!(to_fts_query("NEAR(a b)"), Some(r#""NEAR(a" "b)""#.into())); + } + + async fn indexed_db(listings: &[(&str, &str, Game, &str)]) -> SqlitePool { + let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&db).await.unwrap(); + + for (i, (title, description, game, handle)) in listings.iter().enumerate() { + let did = Did::new(format!("did:plc:author{i}")); + actor::upsert(&db, &did, &Handle::new(handle).unwrap()) + .await + .unwrap(); + + let listing = ModListing { + title: title.to_string(), + slug: format!("mod-{i}"), + description: description.to_string(), + details: String::new(), + game: *game, + category: String::new(), + tags: vec![], + license: String::new(), + media: vec![], + created_at: chrono::Utc::now(), + }; + listing + .save_local(&db, &did, &Rkey::from_verified(format!("r{i}"))) + .await + .unwrap(); + } + + db + } + + fn titles(hits: &[Hit]) -> Vec<&str> { + hits.iter().map(|hit| hit.title.as_str()).collect() + } + + #[tokio::test] + async fn finds_a_mod_by_title_description_game_or_author() { + let db = indexed_db(&[ + ( + "Master Quest", + "A harder Paper Mario", + Game::Pm64, + "lily.test", + ), + ("Sticker Redux", "Rebalanced stickers", Game::Ss, "nic.test"), + ]) + .await; + + assert_eq!( + titles(&search(&db, "master").await.unwrap()), + ["Master Quest"] + ); + assert_eq!( + titles(&search(&db, "rebalanced").await.unwrap()), + ["Sticker Redux"] + ); + // The game reaches its mod by code and by name. + assert_eq!(titles(&search(&db, "ss").await.unwrap()), ["Sticker Redux"]); + assert_eq!( + titles(&search(&db, "origami").await.unwrap()), + Vec::<&str>::new() + ); + assert_eq!( + titles(&search(&db, "nic.test").await.unwrap()), + ["Sticker Redux"] + ); + } + + #[tokio::test] + async fn a_title_match_outranks_the_same_word_in_a_description() { + let db = indexed_db(&[ + ( + "Something Else", + "A quest for the ages", + Game::Pm64, + "lily.test", + ), + ("Master Quest", "A harder mod", Game::Pm64, "nic.test"), + ]) + .await; + + assert_eq!( + titles(&search(&db, "quest").await.unwrap()), + ["Master Quest", "Something Else"] + ); + } + + /// Stemming bridges word forms; a half-typed word is not one of them. + #[tokio::test] + async fn matches_across_stems_but_not_half_typed_words() { + let db = + indexed_db(&[("Sticker Redux", "Rebalanced stickers", Game::Ss, "nic.test")]).await; + + assert_eq!( + titles(&search(&db, "sticker").await.unwrap()), + ["Sticker Redux"] + ); + // The plural in the description reaches the singular query. + assert_eq!( + titles(&search(&db, "stickers").await.unwrap()), + ["Sticker Redux"] + ); + assert!(search(&db, "stick").await.unwrap().is_empty()); + } + + #[tokio::test] + async fn a_deleted_listing_stops_being_found() { + let db = indexed_db(&[("Master Quest", "A harder mod", Game::Pm64, "lily.test")]).await; + let did = Did::new("did:plc:author0"); + + ModListing::delete_local(&db, &did, &Rkey::from_verified("r0")) + .await + .unwrap(); + + assert!(search(&db, "master").await.unwrap().is_empty()); + } + + /// The index spells out the handle rather than joining for it, so a + /// rename has to reach the rows that already named the old one. + #[tokio::test] + async fn renaming_an_author_moves_their_mods_to_the_new_handle() { + let db = indexed_db(&[("Master Quest", "A harder mod", Game::Pm64, "lily.test")]).await; + let did = Did::new("did:plc:author0"); + + actor::upsert(&db, &did, &Handle::new("lilyrose.test").unwrap()) + .await + .unwrap(); + + assert_eq!( + titles(&search(&db, "lilyrose.test").await.unwrap()), + ["Master Quest"] + ); + assert!(search(&db, "lily.test").await.unwrap().is_empty()); + } + + #[test] + fn a_query_of_only_whitespace_searches_for_nothing() { + assert_eq!(to_fts_query(" "), None); + assert_eq!(to_fts_query(""), None); + assert_eq!(to_fts_query(r#" " "#), None); + } + + async fn actor_db() -> SqlitePool { + let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&db).await.unwrap(); + db + } + + #[tokio::test] + async fn finds_a_user_by_part_of_their_handle() { + let db = actor_db().await; + actor::upsert( + &db, + &Did::new("did:plc:lily"), + &Handle::new("lily.starhaven.dev").unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + search_users(&db, "lily", 10).await.unwrap(), + [Handle::new("lily.starhaven.dev").unwrap()] + ); + assert!(search_users(&db, "nobody", 10).await.unwrap().is_empty()); + } + + /// A handle can outlive the DID that first claimed it, since a later + /// claim adds a new row rather than replacing the old one. Only the + /// current owner's claim should show up in results. + #[tokio::test] + async fn a_reassigned_handle_is_only_shown_once() { + let db = actor_db().await; + let handle = Handle::new("alice.test").unwrap(); + actor::upsert(&db, &Did::new("did:plc:old"), &handle) + .await + .unwrap(); + actor::upsert(&db, &Did::new("did:plc:new"), &handle) + .await + .unwrap(); + + assert_eq!(search_users(&db, "alice", 10).await.unwrap(), [handle]); + } +} diff --git a/src/state.rs b/src/state.rs index 563c455..78f1e7a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -69,6 +69,9 @@ impl AppState { .await?; sqlx::migrate!().run(&db).await?; + // Derived from mod_listing, so it is rebuilt rather than migrated. + crate::search::reindex_all(&db).await?; + let s3 = Store::new(&config)?; // Best effort: a mod browser that cannot reach its object store can // still show every mod, so this is a warning rather than a refusal to diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..8747319 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,25 @@ +"""The nav's search box reaching the search page. + +Ranking and matching are covered by unit tests against a real FTS5 index; +what only a browser shows is that the box in the nav goes somewhere. +""" + +from playwright.sync_api import expect + +from helpers import Actor + + +def test_searching_from_the_nav_finds_a_mod(actor: Actor): + actor.login() + actor.create_mod("master-quest", title="Master Quest") + + actor.page.goto(f"{actor.app_url}/") + actor.page.get_by_placeholder("Search").fill("master") + actor.page.get_by_placeholder("Search").press("Enter") + + expect(actor.page).to_have_url(f"{actor.app_url}/search?q=master") + result = actor.page.get_by_role("link", name="Master Quest") + expect(result).to_be_visible() + + result.click() + expect(actor.page).to_have_url(f"{actor.app_url}/{actor.handle}/master-quest")