diff --git a/drizzle/0030_atproto_profile_stats.sql b/drizzle/0030_atproto_profile_stats.sql new file mode 100644 index 0000000..48f514a --- /dev/null +++ b/drizzle/0030_atproto_profile_stats.sql @@ -0,0 +1,4 @@ +ALTER TABLE "users" ADD COLUMN "followers_count" integer DEFAULT 0 NOT NULL; +ALTER TABLE "users" ADD COLUMN "follows_count" integer DEFAULT 0 NOT NULL; +ALTER TABLE "users" ADD COLUMN "atproto_posts_count" integer DEFAULT 0 NOT NULL; +ALTER TABLE "users" ADD COLUMN "has_bluesky_profile" boolean DEFAULT false NOT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9a8935f..aa68642 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1771790400000, "tag": "0029_vote_system", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1771876800000, + "tag": "0030_atproto_profile_stats", + "breakpoints": true } ] } diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index 33e4539..ea07e44 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -25,6 +25,10 @@ export const users = pgTable( .default('safe'), /** Account creation date resolved from PLC directory on first encounter. */ accountCreatedAt: timestamp('account_created_at', { withTimezone: true }), + followersCount: integer('followers_count').notNull().default(0), + followsCount: integer('follows_count').notNull().default(0), + atprotoPostsCount: integer('atproto_posts_count').notNull().default(0), + hasBlueskyProfile: boolean('has_bluesky_profile').notNull().default(false), }, (table) => [ index('users_role_elevated_idx') diff --git a/src/lib/sanitize-text.ts b/src/lib/sanitize-text.ts new file mode 100644 index 0000000..751f919 --- /dev/null +++ b/src/lib/sanitize-text.ts @@ -0,0 +1,7 @@ +/** + * Strips invisible control characters (zero-width, RTL/LTR overrides, + * bidi isolates, BOM) and trims whitespace. + */ +export function stripControlCharacters(text: string): string { + return text.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '').trim() +} diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts index 676c4aa..b6d5ed8 100644 --- a/src/routes/profiles.ts +++ b/src/routes/profiles.ts @@ -12,6 +12,7 @@ import { resolveProfile } from '../lib/resolve-profile.js' import { topics } from '../db/schema/topics.js' import { replies } from '../db/schema/replies.js' import { reactions } from '../db/schema/reactions.js' +import { votes } from '../db/schema/votes.js' import { notifications } from '../db/schema/notifications.js' import { reports } from '../db/schema/reports.js' import { userPreferences, userCommunityPreferences } from '../db/schema/user-preferences.js' @@ -37,12 +38,27 @@ const profileJsonSchema = { role: { type: 'string' as const }, firstSeenAt: { type: 'string' as const, format: 'date-time' as const }, lastActiveAt: { type: 'string' as const, format: 'date-time' as const }, + followersCount: { type: 'number' as const }, + followsCount: { type: 'number' as const }, + atprotoPostsCount: { type: 'number' as const }, + hasBlueskyProfile: { type: 'boolean' as const }, + communityCount: { type: 'number' as const }, activity: { type: 'object' as const, properties: { topicCount: { type: 'number' as const }, replyCount: { type: 'number' as const }, reactionsReceived: { type: 'number' as const }, + votesReceived: { type: 'number' as const }, + }, + }, + globalActivity: { + type: ['object', 'null'] as const, + properties: { + topicCount: { type: 'number' as const }, + replyCount: { type: 'number' as const }, + reactionsReceived: { type: 'number' as const }, + votesReceived: { type: 'number' as const }, }, }, }, @@ -239,6 +255,99 @@ export function profileRoutes(): FastifyPluginCallback { const reactionsReceived = (reactionsOnTopicsResult[0]?.count ?? 0) + (reactionsOnRepliesResult[0]?.count ?? 0) + // Count votes received on user's topics and replies + const votesOnTopicsResult = await db + .select({ count: sql`count(*)::int` }) + .from(votes) + .where( + sql`${votes.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did})` + ) + + const votesOnRepliesResult = await db + .select({ count: sql`count(*)::int` }) + .from(votes) + .where( + sql`${votes.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did})` + ) + + const votesReceived = + (votesOnTopicsResult[0]?.count ?? 0) + (votesOnRepliesResult[0]?.count ?? 0) + + // Count distinct communities the user has contributed to + const topicCommResult = await db + .selectDistinct({ communityDid: topics.communityDid }) + .from(topics) + .where(eq(topics.authorDid, user.did)) + + const replyCommResult = await db + .selectDistinct({ communityDid: replies.communityDid }) + .from(replies) + .where(eq(replies.authorDid, user.did)) + + const allCommunities = new Set([ + ...topicCommResult.map((r: { communityDid: string }) => r.communityDid), + ...replyCommResult.map((r: { communityDid: string }) => r.communityDid), + ]) + + const communityCount = allCommunities.size + + // Community-scoped activity (when communityDid is provided) + let scopedActivity: { + topicCount: number + replyCount: number + reactionsReceived: number + votesReceived: number + } | null = null + + if (communityDid) { + const scopedTopicResult = await db + .select({ count: sql`count(*)::int` }) + .from(topics) + .where(and(eq(topics.authorDid, user.did), eq(topics.communityDid, communityDid))) + + const scopedReplyResult = await db + .select({ count: sql`count(*)::int` }) + .from(replies) + .where(and(eq(replies.authorDid, user.did), eq(replies.communityDid, communityDid))) + + const scopedReactionsOnTopics = await db + .select({ count: sql`count(*)::int` }) + .from(reactions) + .where( + sql`${reactions.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did} AND ${topics.communityDid} = ${communityDid})` + ) + + const scopedReactionsOnReplies = await db + .select({ count: sql`count(*)::int` }) + .from(reactions) + .where( + sql`${reactions.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did} AND ${replies.communityDid} = ${communityDid})` + ) + + const scopedVotesOnTopics = await db + .select({ count: sql`count(*)::int` }) + .from(votes) + .where( + sql`${votes.subjectUri} IN (SELECT ${topics.uri} FROM ${topics} WHERE ${topics.authorDid} = ${user.did} AND ${topics.communityDid} = ${communityDid})` + ) + + const scopedVotesOnReplies = await db + .select({ count: sql`count(*)::int` }) + .from(votes) + .where( + sql`${votes.subjectUri} IN (SELECT ${replies.uri} FROM ${replies} WHERE ${replies.authorDid} = ${user.did} AND ${replies.communityDid} = ${communityDid})` + ) + + scopedActivity = { + topicCount: scopedTopicResult[0]?.count ?? 0, + replyCount: scopedReplyResult[0]?.count ?? 0, + reactionsReceived: + (scopedReactionsOnTopics[0]?.count ?? 0) + (scopedReactionsOnReplies[0]?.count ?? 0), + votesReceived: + (scopedVotesOnTopics[0]?.count ?? 0) + (scopedVotesOnReplies[0]?.count ?? 0), + } + } + // Build source profile for resolution const sourceProfile = { did: user.did, @@ -266,7 +375,14 @@ export function profileRoutes(): FastifyPluginCallback { resolved = resolveProfile(sourceProfile, override) } - return reply.status(200).send({ + const globalActivity = { + topicCount, + replyCount, + reactionsReceived, + votesReceived, + } + + const responseBody: Record = { did: resolved.did, handle: resolved.handle, displayName: resolved.displayName, @@ -276,12 +392,19 @@ export function profileRoutes(): FastifyPluginCallback { role: user.role, firstSeenAt: user.firstSeenAt.toISOString(), lastActiveAt: user.lastActiveAt.toISOString(), - activity: { - topicCount, - replyCount, - reactionsReceived, - }, - }) + followersCount: user.followersCount, + followsCount: user.followsCount, + atprotoPostsCount: user.atprotoPostsCount, + hasBlueskyProfile: user.hasBlueskyProfile, + communityCount, + activity: scopedActivity ?? globalActivity, + } + + if (communityDid && communityCount >= 2) { + responseBody['globalActivity'] = globalActivity + } + + return reply.status(200).send(responseBody) } ) diff --git a/src/services/profile-sync.ts b/src/services/profile-sync.ts index 5c93591..7d92609 100644 --- a/src/services/profile-sync.ts +++ b/src/services/profile-sync.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm' import type { Logger } from '../lib/logger.js' import type { Database } from '../db/index.js' import { users } from '../db/schema/users.js' +import { stripControlCharacters } from '../lib/sanitize-text.js' // --------------------------------------------------------------------------- // Types @@ -14,6 +15,10 @@ export interface ProfileData { avatarUrl: string | null bannerUrl: string | null bio: string | null + followersCount: number + followsCount: number + atprotoPostsCount: number + hasBlueskyProfile: boolean } export interface ProfileSyncService { @@ -26,6 +31,10 @@ const NULL_PROFILE: ProfileData = { avatarUrl: null, bannerUrl: null, bio: null, + followersCount: 0, + followsCount: 0, + atprotoPostsCount: 0, + hasBlueskyProfile: false, } // --------------------------------------------------------------------------- @@ -42,6 +51,9 @@ interface AgentLike { avatar?: string banner?: string description?: string + followersCount?: number + followsCount?: number + postsCount?: number } }> } @@ -83,11 +95,16 @@ export function createProfileSyncService( try { const agent = agentFactory.createAgent() const response = await agent.getProfile({ actor: did }) + const sanitizedName = stripControlCharacters(response.data.displayName ?? '') profileData = { - displayName: response.data.displayName ?? null, + displayName: sanitizedName || null, avatarUrl: response.data.avatar ?? null, bannerUrl: response.data.banner ?? null, bio: response.data.description ?? null, + followersCount: response.data.followersCount ?? 0, + followsCount: response.data.followsCount ?? 0, + atprotoPostsCount: response.data.postsCount ?? 0, + hasBlueskyProfile: true, } } catch (err: unknown) { logger.debug({ did, err }, 'profile sync failed: could not fetch profile from public API') @@ -103,6 +120,10 @@ export function createProfileSyncService( avatarUrl: profileData.avatarUrl, bannerUrl: profileData.bannerUrl, bio: profileData.bio, + followersCount: profileData.followersCount, + followsCount: profileData.followsCount, + atprotoPostsCount: profileData.atprotoPostsCount, + hasBlueskyProfile: profileData.hasBlueskyProfile, lastActiveAt: new Date(), }) .where(eq(users.did, did)) diff --git a/tests/unit/lib/sanitize-text.test.ts b/tests/unit/lib/sanitize-text.test.ts new file mode 100644 index 0000000..8dd9634 --- /dev/null +++ b/tests/unit/lib/sanitize-text.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { stripControlCharacters } from '../../../src/lib/sanitize-text.js' + +describe('stripControlCharacters', () => { + it('strips RTL override characters', () => { + expect(stripControlCharacters('Hello\u202Eworld')).toBe('Helloworld') + }) + + it('strips zero-width characters', () => { + expect(stripControlCharacters('He\u200Bllo')).toBe('Hello') + }) + + it('strips bidi isolate characters', () => { + expect(stripControlCharacters('\u2066Hello\u2069')).toBe('Hello') + }) + + it('strips BOM character', () => { + expect(stripControlCharacters('\uFEFFHello')).toBe('Hello') + }) + + it('preserves normal Unicode (accents, CJK)', () => { + expect(stripControlCharacters('Héllo')).toBe('Héllo') + expect(stripControlCharacters('こんにちは')).toBe('こんにちは') + expect(stripControlCharacters('Ñoño')).toBe('Ñoño') + }) + + it('returns empty string for all-control input', () => { + expect(stripControlCharacters('\u200B\u200C\u200D')).toBe('') + }) + + it('trims whitespace', () => { + expect(stripControlCharacters(' Hello ')).toBe('Hello') + }) + + it('handles empty string', () => { + expect(stripControlCharacters('')).toBe('') + }) +}) diff --git a/tests/unit/routes/profiles.test.ts b/tests/unit/routes/profiles.test.ts index c426af3..5a34fc2 100644 --- a/tests/unit/routes/profiles.test.ts +++ b/tests/unit/routes/profiles.test.ts @@ -63,6 +63,10 @@ function sampleUserRow(overrides?: Record) { lastActiveAt: new Date(TEST_NOW), declaredAge: null, maturityPref: 'safe', + followersCount: 0, + followsCount: 0, + atprotoPostsCount: 0, + hasBlueskyProfile: false, ...overrides, } } @@ -285,6 +289,14 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // 5th select: reactions on replies selectChain.where.mockResolvedValueOnce([{ count: 2 }]) + // 6th select: votes on topics + selectChain.where.mockResolvedValueOnce([{ count: 4 }]) + // 7th select: votes on replies + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) + // 8th selectDistinct: topic communities + selectDistinctChain.where.mockResolvedValueOnce([{ communityDid: 'did:plc:comm1' }]) + // 9th selectDistinct: reply communities + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ method: 'GET', @@ -299,10 +311,16 @@ describe('profile routes', () => { bannerUrl: string | null bio: string | null role: string + followersCount: number + followsCount: number + atprotoPostsCount: number + hasBlueskyProfile: boolean + communityCount: number activity: { topicCount: number replyCount: number reactionsReceived: number + votesReceived: number } }>() expect(body.did).toBe(TEST_DID) @@ -314,6 +332,12 @@ describe('profile routes', () => { expect(body.activity.topicCount).toBe(5) expect(body.activity.replyCount).toBe(10) expect(body.activity.reactionsReceived).toBe(5) + expect(body.activity.votesReceived).toBe(5) + expect(body.followersCount).toBe(0) + expect(body.followsCount).toBe(0) + expect(body.atprotoPostsCount).toBe(0) + expect(body.hasBlueskyProfile).toBe(false) + expect(body.communityCount).toBe(1) }) it('returns null for bannerUrl and bio when not set', async () => { @@ -322,6 +346,12 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ method: 'GET', @@ -348,7 +378,20 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // 5th select: reactions on replies selectChain.where.mockResolvedValueOnce([{ count: 0 }]) - // 6th select: community_profiles override + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) + // community-scoped counts (6 queries) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // community_profiles override selectChain.where.mockResolvedValueOnce([ { did: TEST_DID, @@ -391,7 +434,20 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) - // 6th select: no community_profiles row + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) + // community-scoped counts (6 queries) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // no community_profiles row selectChain.where.mockResolvedValueOnce([]) const response = await app.inject({ @@ -430,6 +486,12 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([]) + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([]) + selectChain.where.mockResolvedValueOnce([]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ method: 'GET', @@ -442,11 +504,13 @@ describe('profile routes', () => { topicCount: number replyCount: number reactionsReceived: number + votesReceived: number } }>() expect(body.activity.topicCount).toBe(0) expect(body.activity.replyCount).toBe(0) expect(body.activity.reactionsReceived).toBe(0) + expect(body.activity.votesReceived).toBe(0) }) it('returns null for displayName and avatarUrl when user fields are undefined', async () => { @@ -457,6 +521,12 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ method: 'GET', @@ -478,6 +548,12 @@ describe('profile routes', () => { selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // votes on topics + replies + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount + selectDistinctChain.where.mockResolvedValueOnce([]) + selectDistinctChain.where.mockResolvedValueOnce([]) const response = await app.inject({ method: 'GET', @@ -492,6 +568,118 @@ describe('profile routes', () => { expect(body.firstSeenAt).toBe(TEST_NOW) expect(body.lastActiveAt).toBe(TEST_NOW) }) + + // ----------------------------------------------------------------------- + // Community-scoped activity + // ----------------------------------------------------------------------- + + it('returns community-scoped activity when communityDid is provided', async () => { + // 1: user by handle + selectChain.where.mockResolvedValueOnce([ + sampleUserRow({ + followersCount: 50, + followsCount: 30, + atprotoPostsCount: 100, + hasBlueskyProfile: true, + }), + ]) + // 2-5: global counts + selectChain.where.mockResolvedValueOnce([{ count: 10 }]) // topics + selectChain.where.mockResolvedValueOnce([{ count: 20 }]) // replies + selectChain.where.mockResolvedValueOnce([{ count: 5 }]) // reactions on topics + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // reactions on replies + // 6-7: votes + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) // votes on topics + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) // votes on replies + // 8-9: communityCount + selectDistinctChain.where.mockResolvedValueOnce([ + { communityDid: 'did:plc:comm1' }, + { communityDid: 'did:plc:comm2' }, + ]) + selectDistinctChain.where.mockResolvedValueOnce([{ communityDid: 'did:plc:comm1' }]) + // 10-15: community-scoped counts + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) // scoped topics + selectChain.where.mockResolvedValueOnce([{ count: 8 }]) // scoped replies + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) // scoped reactions on topics + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) // scoped reactions on replies + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) // scoped votes on topics + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) // scoped votes on replies + // community profile override + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/users/${TEST_HANDLE}?communityDid=${COMMUNITY_DID}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + activity: { + topicCount: number + replyCount: number + reactionsReceived: number + votesReceived: number + } + globalActivity: { + topicCount: number + replyCount: number + reactionsReceived: number + votesReceived: number + } + followersCount: number + hasBlueskyProfile: boolean + }>() + // Activity should be community-scoped + expect(body.activity.topicCount).toBe(3) + expect(body.activity.replyCount).toBe(8) + expect(body.activity.reactionsReceived).toBe(3) + expect(body.activity.votesReceived).toBe(1) + // Global activity should be present (2 communities) + expect(body.globalActivity).toBeDefined() + expect(body.globalActivity.topicCount).toBe(10) + // AT Protocol stats + expect(body.followersCount).toBe(50) + expect(body.hasBlueskyProfile).toBe(true) + }) + + it('omits globalActivity when user is in only 1 community', async () => { + selectChain.where.mockResolvedValueOnce([sampleUserRow()]) + // global counts + selectChain.where.mockResolvedValueOnce([{ count: 5 }]) + selectChain.where.mockResolvedValueOnce([{ count: 10 }]) + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // communityCount = 1 + selectDistinctChain.where.mockResolvedValueOnce([{ communityDid: COMMUNITY_DID }]) + selectDistinctChain.where.mockResolvedValueOnce([]) + // community-scoped counts (same as global for 1 community) + selectChain.where.mockResolvedValueOnce([{ count: 5 }]) + selectChain.where.mockResolvedValueOnce([{ count: 10 }]) + selectChain.where.mockResolvedValueOnce([{ count: 3 }]) + selectChain.where.mockResolvedValueOnce([{ count: 2 }]) + selectChain.where.mockResolvedValueOnce([{ count: 1 }]) + selectChain.where.mockResolvedValueOnce([{ count: 0 }]) + // community override + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/users/${TEST_HANDLE}?communityDid=${COMMUNITY_DID}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ + globalActivity?: { + topicCount: number + replyCount: number + reactionsReceived: number + votesReceived: number + } + }>() + expect(body.globalActivity).toBeUndefined() + }) }) // ========================================================================= diff --git a/tests/unit/services/profile-sync.test.ts b/tests/unit/services/profile-sync.test.ts index 9a36c78..50bdece 100644 --- a/tests/unit/services/profile-sync.test.ts +++ b/tests/unit/services/profile-sync.test.ts @@ -58,6 +58,9 @@ const MOCK_PROFILE_RESPONSE = { avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:testuser123456789012/bafkreiabc@jpeg', banner: 'https://cdn.bsky.app/img/banner/plain/did:plc:testuser123456789012/bafkreixyz@jpeg', description: 'Exploring the decentralized web.', + followersCount: 150, + followsCount: 75, + postsCount: 230, }, } @@ -105,6 +108,10 @@ describe('ProfileSyncService', () => { bannerUrl: 'https://cdn.bsky.app/img/banner/plain/did:plc:testuser123456789012/bafkreixyz@jpeg', bio: 'Exploring the decentralized web.', + followersCount: 150, + followsCount: 75, + atprotoPostsCount: 230, + hasBlueskyProfile: true, }) }) @@ -134,6 +141,10 @@ describe('ProfileSyncService', () => { avatarUrl: null, bannerUrl: null, bio: null, + followersCount: 0, + followsCount: 0, + atprotoPostsCount: 0, + hasBlueskyProfile: true, }) }) @@ -151,6 +162,10 @@ describe('ProfileSyncService', () => { avatarUrl: null, bannerUrl: null, bio: null, + followersCount: 0, + followsCount: 0, + atprotoPostsCount: 0, + hasBlueskyProfile: false, }) }) @@ -190,6 +205,10 @@ describe('ProfileSyncService', () => { bannerUrl: 'https://cdn.bsky.app/img/banner/plain/did:plc:testuser123456789012/bafkreixyz@jpeg', bio: 'Exploring the decentralized web.', + followersCount: 150, + followsCount: 75, + atprotoPostsCount: 230, + hasBlueskyProfile: true, }) }) @@ -212,4 +231,60 @@ describe('ProfileSyncService', () => { expect.stringContaining('profile DB update failed') as string ) }) + + // ------------------------------------------------------------------------- + // AT Protocol stats capture + // ------------------------------------------------------------------------- + + it('captures followersCount, followsCount, and atprotoPostsCount from profile response', async () => { + const result = await service.syncProfile(TEST_DID) + + expect(result.followersCount).toBe(150) + expect(result.followsCount).toBe(75) + expect(result.atprotoPostsCount).toBe(230) + }) + + it('sets hasBlueskyProfile to true when fetch succeeds', async () => { + const result = await service.syncProfile(TEST_DID) + + expect(result.hasBlueskyProfile).toBe(true) + }) + + it('sets hasBlueskyProfile to false when fetch fails', async () => { + mockGetProfile.mockRejectedValue(new Error('Profile not found')) + + const result = await service.syncProfile(TEST_DID) + + expect(result.hasBlueskyProfile).toBe(false) + }) + + // ------------------------------------------------------------------------- + // Display name sanitization + // ------------------------------------------------------------------------- + + it('strips control characters from displayName', async () => { + mockGetProfile.mockResolvedValue({ + success: true, + data: { + ...MOCK_PROFILE_RESPONSE.data, + displayName: 'Alice\u200BWonderland', + }, + }) + + const result = await service.syncProfile(TEST_DID) + expect(result.displayName).toBe('AliceWonderland') + }) + + it('returns null displayName when name is all control characters', async () => { + mockGetProfile.mockResolvedValue({ + success: true, + data: { + ...MOCK_PROFILE_RESPONSE.data, + displayName: '\u200B\u200C\u200D', + }, + }) + + const result = await service.syncProfile(TEST_DID) + expect(result.displayName).toBeNull() + }) })