learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: add user profile functionality

* enhance search results with source information

+521 -7
+1
crates/server/src/api/mod.rs
··· 9 9 pub mod review; 10 10 pub mod search; 11 11 pub mod social; 12 + pub mod users;
+1
crates/server/src/api/search.rs
··· 121 121 creator_did: "did:alice".to_string(), 122 122 data: serde_json::json!({ "title": "Secret", "visibility": { "type": "Private" } }), 123 123 rank: 1.0, 124 + source: "local".to_string(), 124 125 }) 125 126 .await; 126 127
+99
crates/server/src/api/users.rs
··· 1 + use crate::state::SharedState; 2 + 3 + use axum::{ 4 + Json, 5 + extract::{Path, State}, 6 + http::StatusCode, 7 + response::IntoResponse, 8 + }; 9 + use serde_json::json; 10 + 11 + /// GET /api/users/:did/profile 12 + /// Get aggregated profile information for a user 13 + pub async fn get_profile(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse { 14 + match state.social_repo.get_user_profile(&did).await { 15 + Ok(profile) => Json(profile).into_response(), 16 + Err(e) => { 17 + tracing::error!("Failed to get user profile: {:?}", e); 18 + ( 19 + StatusCode::INTERNAL_SERVER_ERROR, 20 + Json(json!({"error": "Failed to get user profile"})), 21 + ) 22 + .into_response() 23 + } 24 + } 25 + } 26 + 27 + #[cfg(test)] 28 + mod tests { 29 + use super::*; 30 + use crate::repository::card::mock::MockCardRepository; 31 + use crate::repository::deck::mock::MockDeckRepository; 32 + use crate::repository::note::mock::MockNoteRepository; 33 + use crate::repository::oauth::mock::MockOAuthRepository; 34 + use crate::repository::preferences::mock::MockPreferencesRepository; 35 + use crate::repository::review::mock::MockReviewRepository; 36 + use crate::repository::search::mock::MockSearchRepository; 37 + use crate::repository::social::mock::MockSocialRepository; 38 + use crate::repository::social::{SocialRepository, UserProfile}; 39 + use crate::state::AppState; 40 + use std::sync::Arc; 41 + 42 + fn create_test_state(social_repo: Arc<MockSocialRepository>) -> SharedState { 43 + let pool = crate::db::create_mock_pool(); 44 + Arc::new(AppState { 45 + pool, 46 + card_repo: Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>, 47 + note_repo: Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>, 48 + oauth_repo: Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>, 49 + prefs_repo: Arc::new(MockPreferencesRepository::new()) 50 + as Arc<dyn crate::repository::preferences::PreferencesRepository>, 51 + review_repo: Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>, 52 + social_repo: social_repo.clone() as Arc<dyn SocialRepository>, 53 + deck_repo: Arc::new(MockDeckRepository::new()) as Arc<dyn crate::repository::deck::DeckRepository>, 54 + search_repo: Arc::new(MockSearchRepository::new()) as Arc<dyn crate::repository::search::SearchRepository>, 55 + config: crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }, 56 + auth_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 57 + }) 58 + } 59 + 60 + #[tokio::test] 61 + async fn test_get_profile_returns_counts() { 62 + let social_repo = Arc::new(MockSocialRepository::new()); 63 + social_repo.follow("did:follower1", "did:user").await.unwrap(); 64 + social_repo.follow("did:follower2", "did:user").await.unwrap(); 65 + social_repo.follow("did:user", "did:following").await.unwrap(); 66 + 67 + let state = create_test_state(social_repo); 68 + let response = get_profile(State(state), Path("did:user".to_string())) 69 + .await 70 + .into_response(); 71 + 72 + assert_eq!(response.status(), StatusCode::OK); 73 + 74 + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 75 + let profile: UserProfile = serde_json::from_slice(&body).unwrap(); 76 + 77 + assert_eq!(profile.did, "did:user"); 78 + assert_eq!(profile.follower_count, 2); 79 + assert_eq!(profile.following_count, 1); 80 + } 81 + 82 + #[tokio::test] 83 + async fn test_get_profile_unknown_user_returns_zero() { 84 + let social_repo = Arc::new(MockSocialRepository::new()); 85 + let state = create_test_state(social_repo); 86 + 87 + let response = get_profile(State(state), Path("did:unknown".to_string())) 88 + .await 89 + .into_response(); 90 + 91 + assert_eq!(response.status(), StatusCode::OK); 92 + 93 + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 94 + let profile: UserProfile = serde_json::from_slice(&body).unwrap(); 95 + 96 + assert_eq!(profile.follower_count, 0); 97 + assert_eq!(profile.following_count, 0); 98 + } 99 + }
+1
crates/server/src/lib.rs
··· 102 102 .route("/feeds/trending", get(api::feed::get_feed_trending)) 103 103 .route("/search", get(api::search::search)) 104 104 .route("/discovery", get(api::search::discovery)) 105 + .route("/users/{did}/profile", get(api::users::get_profile)) 105 106 .layer(axum_middleware::from_fn_with_state( 106 107 state.clone(), 107 108 middleware::auth::optional_auth_middleware,
+4 -1
crates/server/src/repository/search.rs
··· 9 9 pub creator_did: String, 10 10 pub data: serde_json::Value, 11 11 pub rank: f32, 12 + pub source: String, 12 13 } 13 14 14 15 #[async_trait::async_trait] ··· 46 47 item_id, 47 48 creator_did, 48 49 data, 49 - ts_rank(tsv_content, websearch_to_tsquery('english', $1)) as rank 50 + ts_rank(tsv_content, websearch_to_tsquery('english', $1)) as rank, 51 + source 50 52 FROM search_items 51 53 WHERE tsv_content @@ websearch_to_tsquery('english', $1) 52 54 AND ( ··· 70 72 creator_did: row.get("creator_did"), 71 73 data: row.get("data"), 72 74 rank: row.get("rank"), 75 + source: row.get("source"), 73 76 }) 74 77 .collect(); 75 78
+63
crates/server/src/repository/social.rs
··· 2 2 use chrono::Utc; 3 3 use malfestio_core::error::Error; 4 4 use malfestio_core::model::{Comment, Deck, Visibility}; 5 + use serde::{Deserialize, Serialize}; 5 6 6 7 use crate::db; 7 8 9 + /// Aggregated user profile with follower counts and deck statistics 10 + #[derive(Debug, Clone, Serialize, Deserialize)] 11 + pub struct UserProfile { 12 + pub did: String, 13 + pub follower_count: i64, 14 + pub following_count: i64, 15 + pub deck_count: i64, 16 + pub indexed_deck_count: i64, 17 + } 18 + 8 19 #[async_trait] 9 20 pub trait SocialRepository: Send + Sync { 10 21 async fn follow(&self, follower: &str, subject: &str) -> Result<(), Error>; ··· 17 28 async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, Error>; 18 29 async fn get_feed_follows(&self, user_did: &str) -> Result<Vec<Deck>, Error>; 19 30 async fn get_feed_trending(&self) -> Result<Vec<Deck>, Error>; 31 + async fn get_user_profile(&self, did: &str) -> Result<UserProfile, Error>; 20 32 } 21 33 22 34 pub struct DbSocialRepository { ··· 255 267 256 268 Ok(Self::parse_deck_rows(rows)) 257 269 } 270 + 271 + async fn get_user_profile(&self, did: &str) -> Result<UserProfile, Error> { 272 + let client = self 273 + .pool 274 + .get() 275 + .await 276 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 277 + 278 + let follower_row = client 279 + .query_one("SELECT COUNT(*) as count FROM follows WHERE subject_did = $1", &[&did]) 280 + .await 281 + .map_err(|e| Error::Database(format!("Failed to get follower count: {}", e)))?; 282 + let follower_count: i64 = follower_row.get("count"); 283 + 284 + let following_row = client 285 + .query_one("SELECT COUNT(*) as count FROM follows WHERE follower_did = $1", &[&did]) 286 + .await 287 + .map_err(|e| Error::Database(format!("Failed to get following count: {}", e)))?; 288 + let following_count: i64 = following_row.get("count"); 289 + 290 + let deck_row = client 291 + .query_one("SELECT COUNT(*) as count FROM decks WHERE owner_did = $1", &[&did]) 292 + .await 293 + .map_err(|e| Error::Database(format!("Failed to get deck count: {}", e)))?; 294 + let deck_count: i64 = deck_row.get("count"); 295 + 296 + let indexed_row = client 297 + .query_one( 298 + "SELECT COUNT(*) as count FROM indexed_decks WHERE did = $1 AND deleted_at IS NULL", 299 + &[&did], 300 + ) 301 + .await 302 + .map_err(|e| Error::Database(format!("Failed to get indexed deck count: {}", e)))?; 303 + let indexed_deck_count: i64 = indexed_row.get("count"); 304 + 305 + Ok(UserProfile { did: did.to_string(), follower_count, following_count, deck_count, indexed_deck_count }) 306 + } 258 307 } 259 308 260 309 #[cfg(test)] ··· 341 390 342 391 async fn get_feed_trending(&self) -> Result<Vec<Deck>, Error> { 343 392 Ok(vec![]) 393 + } 394 + 395 + async fn get_user_profile(&self, did: &str) -> Result<UserProfile, Error> { 396 + let followers = self.followers.lock().unwrap(); 397 + let follower_count = followers.iter().filter(|(_, s)| s == did).count() as i64; 398 + let following_count = followers.iter().filter(|(f, _)| f == did).count() as i64; 399 + 400 + Ok(UserProfile { 401 + did: did.to_string(), 402 + follower_count, 403 + following_count, 404 + deck_count: 0, 405 + indexed_deck_count: 0, 406 + }) 344 407 } 345 408 } 346 409 }
+2 -2
docs/todo.md
··· 44 44 45 45 **Search & Discovery:** 46 46 47 - - [ ] Implement search over indexed remote records (extend `search_items` view) 48 - - [ ] User profile aggregation: follower counts, deck counts from federated sources 47 + - [x] Implement search over indexed remote records (extend `search_items` view) 48 + - [x] User profile aggregation: follower counts, deck counts from federated sources 49 49 50 50 **Export & Interop:** 51 51
+125
migrations/010_2025_12_31_extended_search.sql
··· 1 + -- Migration: Extend search_items view to include indexed remote records 2 + 3 + DROP MATERIALIZED VIEW IF EXISTS search_items; 4 + 5 + CREATE MATERIALIZED VIEW search_items AS 6 + 7 + SELECT 8 + 'deck' AS item_type, 9 + id::text AS item_id, 10 + owner_did AS creator_did, 11 + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || 12 + setweight(to_tsvector('english', unaccent(coalesce(description, ''))), 'B') AS tsv_content, 13 + jsonb_build_object( 14 + 'id', id, 15 + 'title', title, 16 + 'description', description, 17 + 'owner_did', owner_did 18 + ) AS data, 19 + visibility, 20 + 'local' AS source 21 + FROM decks 22 + UNION ALL 23 + SELECT 24 + 'card' AS item_type, 25 + c.id::text AS item_id, 26 + c.owner_did AS creator_did, 27 + setweight(to_tsvector('english', unaccent(coalesce(c.front, ''))), 'A') || 28 + setweight(to_tsvector('english', unaccent(coalesce(c.back, ''))), 'B') AS tsv_content, 29 + jsonb_build_object( 30 + 'id', c.id, 31 + 'deck_id', c.deck_id, 32 + 'front', c.front, 33 + 'back', c.back, 34 + 'owner_did', c.owner_did 35 + ) AS data, 36 + d.visibility, 37 + 'local' AS source 38 + FROM cards c 39 + JOIN decks d ON c.deck_id = d.id 40 + UNION ALL 41 + SELECT 42 + 'note' AS item_type, 43 + id::text AS item_id, 44 + owner_did AS creator_did, 45 + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || 46 + setweight(to_tsvector('english', unaccent(coalesce(body, ''))), 'B') AS tsv_content, 47 + jsonb_build_object( 48 + 'id', id, 49 + 'title', title, 50 + 'owner_did', owner_did 51 + ) AS data, 52 + visibility, 53 + 'local' AS source 54 + FROM notes 55 + UNION ALL 56 + -- Indexed remote decks 57 + SELECT 58 + 'deck' AS item_type, 59 + id::text AS item_id, 60 + did AS creator_did, 61 + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || 62 + setweight(to_tsvector('english', unaccent(coalesce(description, ''))), 'B') AS tsv_content, 63 + jsonb_build_object( 64 + 'id', id, 65 + 'title', title, 66 + 'description', description, 67 + 'owner_did', did, 68 + 'at_uri', at_uri, 69 + 'tags', tags 70 + ) AS data, 71 + jsonb_build_object('type', 'Public') AS visibility, 72 + 'remote' AS source 73 + FROM indexed_decks 74 + WHERE deleted_at IS NULL 75 + UNION ALL 76 + -- Indexed remote cards 77 + SELECT 78 + 'card' AS item_type, 79 + id::text AS item_id, 80 + did AS creator_did, 81 + setweight(to_tsvector('english', unaccent(coalesce(front, ''))), 'A') || 82 + setweight(to_tsvector('english', unaccent(coalesce(back, ''))), 'B') AS tsv_content, 83 + jsonb_build_object( 84 + 'id', id, 85 + 'deck_ref', deck_ref, 86 + 'front', front, 87 + 'back', back, 88 + 'owner_did', did, 89 + 'at_uri', at_uri 90 + ) AS data, 91 + jsonb_build_object('type', 'Public') AS visibility, 92 + 'remote' AS source 93 + FROM indexed_cards 94 + WHERE deleted_at IS NULL 95 + UNION ALL 96 + -- Indexed remote notes 97 + SELECT 98 + 'note' AS item_type, 99 + id::text AS item_id, 100 + did AS creator_did, 101 + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || 102 + setweight(to_tsvector('english', unaccent(coalesce(body, ''))), 'B') AS tsv_content, 103 + jsonb_build_object( 104 + 'id', id, 105 + 'title', title, 106 + 'owner_did', did, 107 + 'at_uri', at_uri 108 + ) AS data, 109 + jsonb_build_object('type', visibility) AS visibility, 110 + 'remote' AS source 111 + FROM indexed_notes 112 + WHERE deleted_at IS NULL; 113 + 114 + CREATE UNIQUE INDEX idx_search_items_unique ON search_items (item_type, item_id, source); 115 + CREATE INDEX idx_search_items_tsv ON search_items USING GIN (tsv_content); 116 + CREATE INDEX idx_search_items_meta ON search_items (item_type, creator_did); 117 + CREATE INDEX idx_search_items_visibility ON search_items USING GIN (visibility); 118 + CREATE INDEX idx_search_items_source ON search_items (source); 119 + 120 + CREATE OR REPLACE FUNCTION refresh_search_items() 121 + RETURNS void AS $$ 122 + BEGIN 123 + REFRESH MATERIALIZED VIEW CONCURRENTLY search_items; 124 + END; 125 + $$ LANGUAGE plpgsql;
+38
web/src/components/UserProfileCard.tsx
··· 1 + import { Card } from "$components/ui/Card"; 2 + import type { UserProfile } from "$lib/model"; 3 + import type { Component } from "solid-js"; 4 + 5 + type UserProfileCardProps = { profile: UserProfile; class?: string }; 6 + 7 + export const UserProfileCard: Component<UserProfileCardProps> = (props) => { 8 + return ( 9 + <Card class={`p-6 ${props.class || ""}`}> 10 + <div class="flex items-center gap-4 mb-4"> 11 + <div class="h-16 w-16 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-2xl font-bold"> 12 + {props.profile.did.slice(4, 6).toUpperCase()} 13 + </div> 14 + <div> 15 + <h3 class="text-xl font-bold truncate max-w-[200px]" title={props.profile.did}>{props.profile.did}</h3> 16 + <p class="text-gray-500 text-sm">AT Protocol User</p> 17 + </div> 18 + </div> 19 + 20 + <div class="grid grid-cols-3 gap-4 text-center border-t border-gray-100 dark:border-gray-700 pt-4"> 21 + <div> 22 + <div class="text-2xl font-bold text-gray-900 dark:text-white">{props.profile.follower_count}</div> 23 + <div class="text-xs text-gray-500 uppercase tracking-wide">Followers</div> 24 + </div> 25 + <div> 26 + <div class="text-2xl font-bold text-gray-900 dark:text-white">{props.profile.following_count}</div> 27 + <div class="text-xs text-gray-500 uppercase tracking-wide">Following</div> 28 + </div> 29 + <div> 30 + <div class="text-2xl font-bold text-gray-900 dark:text-white"> 31 + {props.profile.deck_count + props.profile.indexed_deck_count} 32 + </div> 33 + <div class="text-xs text-gray-500 uppercase tracking-wide">Decks</div> 34 + </div> 35 + </div> 36 + </Card> 37 + ); 38 + };
+28
web/src/components/tests/UserProfileCard.test.tsx
··· 1 + import { cleanup, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it } from "vitest"; 3 + import { UserProfileCard } from "../UserProfileCard"; 4 + 5 + describe("UserProfileCard", () => { 6 + afterEach(() => cleanup()); 7 + 8 + const mockProfile = { 9 + did: "did:plc:abcdef123456", 10 + follower_count: 101, 11 + following_count: 202, 12 + deck_count: 303, 13 + indexed_deck_count: 404, 14 + }; 15 + 16 + it("renders user information correctly", () => { 17 + render(() => <UserProfileCard profile={mockProfile} />); 18 + expect(screen.getByText("did:plc:abcdef123456")).toBeInTheDocument(); 19 + expect(screen.getByText("AT Protocol User")).toBeInTheDocument(); 20 + }); 21 + 22 + it("renders statistics correctly", () => { 23 + render(() => <UserProfileCard profile={mockProfile} />); 24 + expect(screen.getByText("101")).toBeInTheDocument(); 25 + expect(screen.getByText("202")).toBeInTheDocument(); 26 + expect(screen.getByText("707")).toBeInTheDocument(); 27 + }); 28 + });
+5 -1
web/src/lib/api.ts
··· 42 42 getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }), 43 43 getPreferences: () => apiFetch("/preferences", { method: "GET" }), 44 44 getDiscovery: () => apiFetch("/discovery", { method: "GET" }), 45 + getUserProfile: (did: string) => apiFetch(`/users/${did}/profile`, { method: "GET" }), 45 46 createDeck: async (payload: CreateDeckPayload) => { 46 47 const { cards, ...deckPayload } = payload; 47 48 const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) }); ··· 57 58 )); 58 59 } 59 60 60 - return { ok: true, json: async () => deck }; 61 + return { 62 + ok: true, 63 + json: async () => deck, 64 + }; 61 65 }, 62 66 addComment: (deckId: string, content: string, parentId?: string) => { 63 67 return apiFetch(`/decks/${deckId}/comments`, {
+18 -1
web/src/lib/model.ts
··· 72 72 73 73 export type FeedFollows = { decks: Deck[] }; 74 74 75 - export type SearchResult = { item_type: "deck"; item_id: string; creator_did: string; data: Deck; rank: number } | { 75 + export type SearchResult = { 76 + item_type: "deck"; 77 + item_id: string; 78 + creator_did: string; 79 + data: Deck; 80 + rank: number; 81 + source?: "local" | "remote"; 82 + } | { 76 83 item_type: "card"; 77 84 item_id: string; 78 85 creator_did: string; 79 86 data: Card & { deck_id: string }; 80 87 rank: number; 88 + source?: "local" | "remote"; 81 89 } | { 82 90 item_type: "note"; 83 91 item_id: string; 84 92 creator_did: string; 85 93 data: { id: string; title: string; owner_did: string }; 86 94 rank: number; 95 + source?: "local" | "remote"; 87 96 }; 88 97 89 98 export const asDeck = (r: SearchResult) => (r.item_type === "deck" ? r : undefined); ··· 97 106 persona: Persona | null; 98 107 onboarding_completed_at: string | null; 99 108 tutorial_deck_completed: boolean; 109 + }; 110 + 111 + export type UserProfile = { 112 + did: string; 113 + follower_count: number; 114 + following_count: number; 115 + deck_count: number; 116 + indexed_deck_count: number; 100 117 }; 101 118 102 119 export type UpdatePreferencesPayload = {
+18
web/src/pages/Discovery.tsx
··· 1 1 import { SearchInput } from "$components/SearchInput"; 2 2 import { Skeleton } from "$components/ui/Skeleton"; 3 3 import { Tag } from "$components/ui/Tag"; 4 + import { UserProfileCard } from "$components/UserProfileCard"; 4 5 import { api } from "$lib/api"; 6 + import { authStore } from "$lib/store"; 5 7 import { A } from "@solidjs/router"; 6 8 import type { Component } from "solid-js"; 7 9 import { createResource, For, Index, Show } from "solid-js"; ··· 12 14 const res = await api.getDiscovery(); 13 15 if (res.ok) return (await res.json()) as { top_tags: [string, number][] }; 14 16 return { top_tags: [] }; 17 + }); 18 + 19 + const [profile] = createResource(() => authStore.user()?.did, async (did) => { 20 + if (!did) return null; 21 + const res = await api.getUserProfile(did); 22 + if (res.ok) return (await res.json()); 23 + return null; 15 24 }); 16 25 17 26 return ( ··· 31 40 <SearchInput /> 32 41 </div> 33 42 </Motion.div> 43 + 44 + <Show when={authStore.isAuthenticated() && profile()}> 45 + <Motion.div 46 + initial={{ opacity: 0, y: 10 }} 47 + animate={{ opacity: 1, y: 0 }} 48 + transition={{ duration: 0.4, delay: 0.1 }}> 49 + <UserProfileCard profile={profile()} /> 50 + </Motion.div> 51 + </Show> 34 52 35 53 <Motion.div 36 54 initial={{ opacity: 0, y: 20 }}
+10 -2
web/src/pages/Search.tsx
··· 100 100 )} 101 101 </Match> 102 102 </Switch> 103 - <div class="mt-2 text-xs text-gray-400"> 104 - Result Type: {result.item_type} • Score: {result.rank.toFixed(2)} 103 + <div class="mt-2 flex items-center gap-3 text-xs text-gray-400"> 104 + <span>Result Type: {result.item_type}</span> 105 + <span>•</span> 106 + <span>Score: {result.rank.toFixed(2)}</span> 107 + <Show when={result.source === "remote"}> 108 + <span class="flex items-center gap-1 text-blue-500 bg-blue-50 dark:bg-blue-900/20 px-2 py-0.5 rounded-full"> 109 + <i class="i-bi-globe2 text-xs" /> 110 + <span>Remote</span> 111 + </span> 112 + </Show> 105 113 </div> 106 114 </div> 107 115 </div>
+80
web/src/pages/tests/Discovery.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { authStore } from "$lib/store"; 3 + import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { JSX } from "solid-js"; 5 + import { afterEach, describe, expect, it, vi } from "vitest"; 6 + import Discovery from "../Discovery"; 7 + 8 + vi.mock("$lib/api", () => ({ api: { getDiscovery: vi.fn(), getUserProfile: vi.fn() } })); 9 + 10 + vi.mock("$lib/store", () => ({ authStore: { user: vi.fn(), isAuthenticated: vi.fn() } })); 11 + 12 + vi.mock( 13 + "@solidjs/router", 14 + () => ({ 15 + A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 16 + useNavigate: () => vi.fn(), 17 + }), 18 + ); 19 + 20 + describe("Discovery", () => { 21 + afterEach(() => { 22 + cleanup(); 23 + vi.clearAllMocks(); 24 + }); 25 + 26 + const mockTopTags = { top_tags: [["rust", 10], ["learning", 5]] }; 27 + 28 + const mockProfile = { 29 + did: "did:test:user", 30 + follower_count: 100, 31 + following_count: 50, 32 + deck_count: 10, 33 + indexed_deck_count: 5, 34 + }; 35 + 36 + it("renders top tags correctly", async () => { 37 + vi.mocked(api.getDiscovery).mockResolvedValue( 38 + { ok: true, json: () => Promise.resolve(mockTopTags) } as unknown as Response, 39 + ); 40 + vi.mocked(authStore.isAuthenticated).mockReturnValue(false); 41 + 42 + render(() => <Discovery />); 43 + 44 + await waitFor(() => expect(screen.getByText("Discover Malfestio")).toBeInTheDocument()); 45 + await waitFor(() => expect(screen.getByText("#rust")).toBeInTheDocument()); 46 + expect(screen.getByText("10")).toBeInTheDocument(); 47 + expect(screen.getByText("#learning")).toBeInTheDocument(); 48 + expect(screen.getByText("5")).toBeInTheDocument(); 49 + }); 50 + 51 + it("shows user profile when logged in", async () => { 52 + vi.mocked(api.getDiscovery).mockResolvedValue( 53 + { ok: true, json: () => Promise.resolve(mockTopTags) } as unknown as Response, 54 + ); 55 + vi.mocked(authStore.isAuthenticated).mockReturnValue(true); 56 + vi.mocked(authStore.user).mockReturnValue({ did: "did:test:user", handle: "test.user" }); 57 + vi.mocked(api.getUserProfile).mockResolvedValue( 58 + { ok: true, json: () => Promise.resolve(mockProfile) } as unknown as Response, 59 + ); 60 + 61 + render(() => <Discovery />); 62 + 63 + await waitFor(() => expect(screen.getByText("did:test:user")).toBeInTheDocument()); 64 + expect(screen.getByText("100")).toBeInTheDocument(); 65 + expect(screen.getByText("50")).toBeInTheDocument(); 66 + expect(screen.getByText("15")).toBeInTheDocument(); 67 + }); 68 + 69 + it("hides user profile when not logged in", async () => { 70 + vi.mocked(api.getDiscovery).mockResolvedValue( 71 + { ok: true, json: () => Promise.resolve(mockTopTags) } as unknown as Response, 72 + ); 73 + vi.mocked(authStore.isAuthenticated).mockReturnValue(false); 74 + 75 + render(() => <Discovery />); 76 + 77 + await waitFor(() => expect(screen.getByText("Discover Malfestio")).toBeInTheDocument()); 78 + expect(screen.queryByText("did:test:user")).not.toBeInTheDocument(); 79 + }); 80 + });
+28
web/src/pages/tests/Search.test.tsx
··· 110 110 const noteLink = screen.getByText("Test Note").closest("a"); 111 111 expect(noteLink).toHaveAttribute("href", "/notes/note1"); 112 112 }); 113 + 114 + it("shows remote indicator for remote results", async () => { 115 + mockSearchParams.q = "remote"; 116 + const remoteResult = { 117 + item_type: "deck", 118 + item_id: "remote-deck", 119 + creator_did: "did:remote:user", 120 + data: { 121 + id: "remote-deck", 122 + owner_did: "did:remote:user", 123 + title: "Remote Deck", 124 + description: "From afar", 125 + tags: [], 126 + visibility: { type: "Public" }, 127 + }, 128 + rank: 1.0, 129 + source: "remote", 130 + }; 131 + 132 + vi.mocked(api.search).mockResolvedValue( 133 + { ok: true, json: () => Promise.resolve([remoteResult]) } as unknown as Response, 134 + ); 135 + 136 + render(() => <Search />); 137 + 138 + await waitFor(() => expect(screen.getByText("Remote Deck")).toBeInTheDocument()); 139 + expect(screen.getByText("Remote")).toBeInTheDocument(); 140 + }); 113 141 });