diff --git a/src/lib/onboarding-gate.ts b/src/lib/onboarding-gate.ts index b172fb9..71f3241 100644 --- a/src/lib/onboarding-gate.ts +++ b/src/lib/onboarding-gate.ts @@ -3,8 +3,11 @@ import { communityOnboardingFields, userOnboardingResponses, } from '../db/schema/onboarding-fields.js' +import { userPreferences } from '../db/schema/user-preferences.js' import type { Database } from '../db/index.js' +const SYSTEM_AGE_FIELD_ID = 'system-age-confirmation' + export interface OnboardingCheckResult { complete: boolean missingFields: { id: string; label: string; fieldType: string }[] @@ -14,6 +17,10 @@ export interface OnboardingCheckResult { * Check whether a user has completed all mandatory onboarding fields * for a community. Returns complete=true if no fields are configured * or all mandatory ones have responses. + * + * Also checks for the system-level age declaration: if no admin-configured + * age_confirmation field exists and the user has no declaredAge, the system + * age field is treated as a missing mandatory field. */ export async function checkOnboardingComplete( db: Database, @@ -31,10 +38,6 @@ export async function checkOnboardingComplete( ) ) - if (fields.length === 0) { - return { complete: true, missingFields: [] } - } - // Get user's responses for this community const responses = await db .select() @@ -52,6 +55,30 @@ export async function checkOnboardingComplete( .filter((f) => !answeredFieldIds.has(f.id)) .map((f) => ({ id: f.id, label: f.label, fieldType: f.fieldType })) + // Check for system-level age field: inject if no admin age field and user has no declaredAge + const allCommunityFields = await db + .select({ fieldType: communityOnboardingFields.fieldType }) + .from(communityOnboardingFields) + .where(eq(communityOnboardingFields.communityDid, communityDid)) + + const hasAdminAgeField = allCommunityFields.some((f) => f.fieldType === 'age_confirmation') + + if (!hasAdminAgeField) { + const prefRows = await db + .select({ declaredAge: userPreferences.declaredAge }) + .from(userPreferences) + .where(eq(userPreferences.did, did)) + + const declaredAge = prefRows[0]?.declaredAge ?? null + if (declaredAge === null) { + missingFields.unshift({ + id: SYSTEM_AGE_FIELD_ID, + label: 'Age Declaration', + fieldType: 'age_confirmation', + }) + } + } + return { complete: missingFields.length === 0, missingFields, diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index ad4bdfb..63cf3c4 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -14,6 +14,9 @@ import { communityOnboardingFields, userOnboardingResponses, } from '../db/schema/onboarding-fields.js' +import { userPreferences } from '../db/schema/user-preferences.js' +import { users } from '../db/schema/users.js' +import { ageDeclarationSchema } from '../validation/profiles.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions @@ -53,6 +56,12 @@ const onboardingStatusJsonSchema = { }, } +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SYSTEM_AGE_FIELD_ID = 'system-age-confirmation' + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -444,13 +453,64 @@ export function onboardingRoutes(): FastifyPluginCallback { const responseMap = new Map(responses.map((r) => [r.fieldId, r.response])) - const fieldsWithStatus = fields.map((field) => ({ + // Check if we need to inject a system-level age_confirmation field + const hasAdminAgeField = fields.some((f) => f.fieldType === 'age_confirmation') + + // Look up user's declared age + const prefRows = await db + .select({ declaredAge: userPreferences.declaredAge }) + .from(userPreferences) + .where(eq(userPreferences.did, user.did)) + + const declaredAge = prefRows[0]?.declaredAge ?? null + const needsSystemAgeField = !hasAdminAgeField && declaredAge === null + + type FieldWithStatus = { + id: string + communityDid: string + fieldType: string + label: string + description: string | null + isMandatory: boolean + sortOrder: number + config: Record | null + createdAt: string + updatedAt: string + completed: boolean + response: unknown + } + + const fieldsWithStatus: FieldWithStatus[] = fields.map((field) => ({ ...serializeField(field), completed: responseMap.has(field.id), response: responseMap.get(field.id) ?? null, })) - const complete = fields.filter((f) => f.isMandatory).every((f) => responseMap.has(f.id)) + // Inject system age field at the beginning if needed + if (needsSystemAgeField) { + const now = new Date().toISOString() + fieldsWithStatus.unshift({ + id: SYSTEM_AGE_FIELD_ID, + communityDid, + fieldType: 'age_confirmation', + label: 'Age Declaration', + description: + 'Please select your age bracket. This determines which content is available to you.', + isMandatory: true, + sortOrder: -1, + config: null, + createdAt: now, + updatedAt: now, + completed: false, + response: null, + }) + } + + // Check completeness: all mandatory fields (including system age field) must be answered + const mandatoryFieldsComplete = fields + .filter((f) => f.isMandatory) + .every((f) => responseMap.has(f.id)) + const complete = mandatoryFieldsComplete && !needsSystemAgeField return reply.status(200).send({ complete, @@ -516,9 +576,13 @@ export function onboardingRoutes(): FastifyPluginCallback { const fieldMap = new Map(fields.map((f) => [f.id, f])) - // Validate each response + // Separate system-level age submissions from admin-configured ones + const systemAgeSubmission = parsed.data.find((s) => s.fieldId === SYSTEM_AGE_FIELD_ID) + const adminSubmissions = parsed.data.filter((s) => s.fieldId !== SYSTEM_AGE_FIELD_ID) + + // Validate admin-configured field responses const errors: string[] = [] - for (const submission of parsed.data) { + for (const submission of adminSubmissions) { const field = fieldMap.get(submission.fieldId) if (!field) { errors.push(`Unknown field: ${submission.fieldId}`) @@ -531,12 +595,35 @@ export function onboardingRoutes(): FastifyPluginCallback { } } + // Validate system age submission + if (systemAgeSubmission) { + const ageParsed = ageDeclarationSchema.safeParse({ + declaredAge: systemAgeSubmission.response, + }) + if (!ageParsed.success) { + errors.push('Age Declaration: must be one of 0, 13, 14, 15, 16, 18') + } + } + + // Also validate admin-configured age_confirmation fields the same way + for (const submission of adminSubmissions) { + const field = fieldMap.get(submission.fieldId) + if (field?.fieldType === 'age_confirmation') { + const ageParsed = ageDeclarationSchema.safeParse({ + declaredAge: submission.response, + }) + if (!ageParsed.success) { + errors.push(`${field.label}: must be one of 0, 13, 14, 15, 16, 18`) + } + } + } + if (errors.length > 0) { throw badRequest(errors.join('; ')) } - // Upsert responses (idempotent) - for (const submission of parsed.data) { + // Upsert admin-field responses (idempotent) + for (const submission of adminSubmissions) { await db .insert(userOnboardingResponses) .values({ @@ -556,6 +643,39 @@ export function onboardingRoutes(): FastifyPluginCallback { completedAt: new Date(), }, }) + + // Sync age_confirmation to user preferences + const field = fieldMap.get(submission.fieldId) + if (field?.fieldType === 'age_confirmation' && typeof submission.response === 'number') { + const now = new Date() + await db + .insert(userPreferences) + .values({ did: user.did, declaredAge: submission.response, updatedAt: now }) + .onConflictDoUpdate({ + target: userPreferences.did, + set: { declaredAge: submission.response, updatedAt: now }, + }) + await db + .update(users) + .set({ declaredAge: submission.response }) + .where(eq(users.did, user.did)) + } + } + + // Handle system-level age submission (syncs to user_preferences + users) + if (systemAgeSubmission && typeof systemAgeSubmission.response === 'number') { + const declaredAge = systemAgeSubmission.response + const now = new Date() + + await db + .insert(userPreferences) + .values({ did: user.did, declaredAge, updatedAt: now }) + .onConflictDoUpdate({ + target: userPreferences.did, + set: { declaredAge, updatedAt: now }, + }) + + await db.update(users).set({ declaredAge }).where(eq(users.did, user.did)) } // Check completeness (all mandatory fields answered?) @@ -570,10 +690,16 @@ export function onboardingRoutes(): FastifyPluginCallback { ) const answeredFieldIds = new Set(existingResponses.map((r) => r.fieldId)) - const complete = fields + const adminFieldsComplete = fields .filter((f) => f.isMandatory) .every((f) => answeredFieldIds.has(f.id)) + // System age field counts as complete if user now has a declaredAge + const systemAgeComplete = systemAgeSubmission + ? typeof systemAgeSubmission.response === 'number' + : true + const complete = adminFieldsComplete && systemAgeComplete + app.log.info( { event: 'onboarding_submitted', diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts index 8aa9a28..8a1d4cb 100644 --- a/src/routes/profiles.ts +++ b/src/routes/profiles.ts @@ -1,10 +1,11 @@ -import { eq, and, sql } from 'drizzle-orm' +import { eq, and, sql, inArray } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' import { userPreferencesSchema, communityPreferencesSchema, ageDeclarationSchema, + resolveHandlesSchema, } from '../validation/profiles.js' import { users } from '../db/schema/users.js' import { communityProfiles } from '../db/schema/community-profiles.js' @@ -22,6 +23,8 @@ import { sybilClusterMembers } from '../db/schema/sybil-cluster-members.js' import { sybilClusters } from '../db/schema/sybil-clusters.js' import { interactionGraph } from '../db/schema/interaction-graph.js' import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' +import { resolveAuthors, type AuthorProfile } from '../lib/resolve-authors.js' +import type { Database } from '../db/index.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions @@ -95,6 +98,16 @@ const reputationJsonSchema = { }, } +const authorProfileJsonSchema = { + type: 'object' as const, + properties: { + did: { type: 'string' as const }, + handle: { type: 'string' as const }, + displayName: { type: ['string', 'null'] as const }, + avatarUrl: { type: ['string', 'null'] as const }, + }, +} + const preferencesJsonSchema = { type: 'object' as const, properties: { @@ -107,6 +120,10 @@ const preferencesJsonSchema = { type: 'array' as const, items: { type: 'string' as const }, }, + blockedProfiles: { + type: 'array' as const, + items: authorProfileJsonSchema, + }, mutedDids: { type: 'array' as const, items: { type: 'string' as const } }, crossPostBluesky: { type: 'boolean' as const }, crossPostFrontpage: { type: 'boolean' as const }, @@ -175,6 +192,21 @@ function defaultCommunityPreferences(communityDid: string) { } } +/** Resolve a list of DIDs to AuthorProfile[], preserving order. */ +async function resolveBlockedProfiles(dids: string[], db: Database): Promise { + if (dids.length === 0) return [] + const profileMap = await resolveAuthors(dids, null, db) + return dids.map( + (did) => + profileMap.get(did) ?? { + did, + handle: did, + displayName: null, + avatarUrl: null, + } + ) +} + // --------------------------------------------------------------------------- // Profile routes plugin // --------------------------------------------------------------------------- @@ -196,6 +228,106 @@ export function profileRoutes(): FastifyPluginCallback { return (app, _opts, done) => { const { db, authMiddleware } = app + // ------------------------------------------------------------------- + // GET /api/users/resolve-handles (auth required) + // ------------------------------------------------------------------- + + app.get( + '/api/users/resolve-handles', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Profiles'], + summary: 'Resolve handles to user profiles', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + required: ['handles'], + properties: { + handles: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object' as const, + properties: { + users: { + type: 'array' as const, + items: authorProfileJsonSchema, + }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const requestUser = request.user + if (!requestUser) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const parsed = resolveHandlesSchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('handles query parameter is required (comma-separated, max 25)') + } + + const handles = parsed.data.handles + + // Look up handles in our users table + const userRows = await db + .select({ + did: users.did, + handle: users.handle, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(inArray(users.handle, handles)) + + const foundMap = new Map() + for (const row of userRows) { + foundMap.set(row.handle, { + did: row.did, + handle: row.handle, + displayName: row.displayName, + avatarUrl: row.avatarUrl, + }) + } + + // For handles not found locally, try AT Protocol identity resolution + // via the public Bluesky AppView XRPC endpoint + const missingHandles = handles.filter((h) => !foundMap.has(h)) + for (const handle of missingHandles) { + try { + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) + if (res.ok) { + const data = (await res.json()) as { did?: string } + if (data.did) { + foundMap.set(handle, { + did: data.did, + handle, + displayName: null, + avatarUrl: null, + }) + } + } + } catch { + // Handle not resolvable -- skip silently + } + } + + // Return in request order + const resolved: AuthorProfile[] = handles + .map((h) => foundMap.get(h)) + .filter((p): p is AuthorProfile => p !== undefined) + + return reply.status(200).send({ users: resolved }) + } + ) + // ------------------------------------------------------------------- // GET /api/users/:handle (public, optionalAuth) // ------------------------------------------------------------------- @@ -718,14 +850,17 @@ export function profileRoutes(): FastifyPluginCallback { const prefs = rows[0] if (!prefs) { - return reply.status(200).send(defaultPreferences()) + return reply.status(200).send({ ...defaultPreferences(), blockedProfiles: [] }) } + const blockedProfiles = await resolveBlockedProfiles(prefs.blockedDids, db) + return reply.status(200).send({ maturityLevel: prefs.maturityLevel, declaredAge: prefs.declaredAge ?? null, mutedWords: prefs.mutedWords, blockedDids: prefs.blockedDids, + blockedProfiles, mutedDids: prefs.mutedDids, crossPostBluesky: prefs.crossPostBluesky, crossPostFrontpage: prefs.crossPostFrontpage, @@ -827,14 +962,17 @@ export function profileRoutes(): FastifyPluginCallback { const prefs = rows[0] if (!prefs) { - return reply.status(200).send(defaultPreferences()) + return reply.status(200).send({ ...defaultPreferences(), blockedProfiles: [] }) } + const updatedBlockedProfiles = await resolveBlockedProfiles(prefs.blockedDids, db) + return reply.status(200).send({ maturityLevel: prefs.maturityLevel, declaredAge: prefs.declaredAge ?? null, mutedWords: prefs.mutedWords, blockedDids: prefs.blockedDids, + blockedProfiles: updatedBlockedProfiles, mutedDids: prefs.mutedDids, crossPostBluesky: prefs.crossPostBluesky, crossPostFrontpage: prefs.crossPostFrontpage, @@ -875,6 +1013,10 @@ export function profileRoutes(): FastifyPluginCallback { type: 'array' as const, items: { type: 'string' as const }, }, + blockedProfiles: { + type: 'array' as const, + items: authorProfileJsonSchema, + }, }, }, }, @@ -905,13 +1047,32 @@ export function profileRoutes(): FastifyPluginCallback { ) .where(eq(userCommunityPreferences.did, requestUser.did)) - const communities = rows.map((row) => ({ - communityDid: row.communityDid, - communityName: row.communityName ?? row.communityDid, - maturityLevel: row.maturityOverride ?? 'inherit', - mutedWords: row.mutedWords ?? [], - blockedDids: row.blockedDids ?? [], - })) + // Collect all blocked DIDs across communities for batch resolution + const allBlockedDids = rows.flatMap((row) => row.blockedDids ?? []) + const profileMap = + allBlockedDids.length > 0 + ? await resolveAuthors([...new Set(allBlockedDids)], null, db) + : new Map() + + const communities = rows.map((row) => { + const dids = row.blockedDids ?? [] + return { + communityDid: row.communityDid, + communityName: row.communityName ?? row.communityDid, + maturityLevel: row.maturityOverride ?? 'inherit', + mutedWords: row.mutedWords ?? [], + blockedDids: dids, + blockedProfiles: dids.map( + (did) => + profileMap.get(did) ?? { + did, + handle: did, + displayName: null, + avatarUrl: null, + } + ), + } + }) // Always include the current community so the settings page shows it // even if the user has never saved per-community preferences. @@ -932,6 +1093,7 @@ export function profileRoutes(): FastifyPluginCallback { maturityLevel: 'inherit', mutedWords: [], blockedDids: [], + blockedProfiles: [], }) } diff --git a/src/validation/profiles.ts b/src/validation/profiles.ts index 11e03fd..49d00bc 100644 --- a/src/validation/profiles.ts +++ b/src/validation/profiles.ts @@ -50,3 +50,23 @@ export const ageDeclarationSchema = z.object({ }) export type AgeDeclarationInput = z.infer + +// --------------------------------------------------------------------------- +// Query schemas +// --------------------------------------------------------------------------- + +/** Schema for GET /api/users/resolve-handles query string. */ +export const resolveHandlesSchema = z.object({ + handles: z + .string() + .min(1) + .transform((val) => + val + .split(',') + .map((h) => h.trim()) + .filter(Boolean) + ) + .pipe(z.array(z.string().min(1)).min(1).max(25)), +}) + +export type ResolveHandlesInput = z.infer diff --git a/tests/unit/lib/onboarding-gate.test.ts b/tests/unit/lib/onboarding-gate.test.ts index d3ad51d..29132c3 100644 --- a/tests/unit/lib/onboarding-gate.test.ts +++ b/tests/unit/lib/onboarding-gate.test.ts @@ -61,7 +61,12 @@ describe('checkOnboardingComplete', () => { }) it('returns complete=true when community has no onboarding fields', async () => { - queueSelectResults([]) // no mandatory fields + queueSelectResults( + [], // no mandatory fields + [], // no user responses + [], // no community fields at all (no admin age field) + [{ declaredAge: 18 }] // user has declared age + ) const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) @@ -71,7 +76,12 @@ describe('checkOnboardingComplete', () => { it('returns complete=true when user has completed all mandatory fields', async () => { const field = sampleField() - queueSelectResults([field], [sampleResponse()]) + queueSelectResults( + [field], // mandatory fields + [sampleResponse()], // user responses + [], // no community fields with age_confirmation + [{ declaredAge: 18 }] // user has declared age + ) const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) @@ -81,7 +91,12 @@ describe('checkOnboardingComplete', () => { it("returns complete=false with missing fields when user hasn't completed mandatory fields", async () => { const field = sampleField() - queueSelectResults([field], []) // no responses + queueSelectResults( + [field], // mandatory fields + [], // no responses + [], // no community fields with age_confirmation + [{ declaredAge: 18 }] // user has declared age (age check passes) + ) const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) @@ -96,7 +111,12 @@ describe('checkOnboardingComplete', () => { const field2 = sampleField({ id: 'field-002', label: 'ToS', fieldType: 'tos_acceptance' }) // Only field-001 answered - queueSelectResults([field1, field2], [sampleResponse({ fieldId: 'field-001' })]) + queueSelectResults( + [field1, field2], // mandatory fields + [sampleResponse({ fieldId: 'field-001' })], // only one response + [], // no community fields with age_confirmation + [{ declaredAge: 18 }] // user has declared age + ) const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) @@ -107,7 +127,12 @@ describe('checkOnboardingComplete', () => { it('only checks mandatory fields (ignores optional)', async () => { // Only query returns mandatory fields, so optional are not fetched - queueSelectResults([], []) // no mandatory fields, no responses + queueSelectResults( + [], // no mandatory fields + [], // no responses + [], // no community fields with age_confirmation + [{ declaredAge: 18 }] // user has declared age + ) const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) diff --git a/tests/unit/routes/onboarding.test.ts b/tests/unit/routes/onboarding.test.ts index 8ac9845..fca7b9a 100644 --- a/tests/unit/routes/onboarding.test.ts +++ b/tests/unit/routes/onboarding.test.ts @@ -588,7 +588,11 @@ describe('onboarding user routes', () => { }) it('returns complete=true when no onboarding fields exist', async () => { - queueSelectResults([], []) // fields, responses + queueSelectResults( + [], // fields + [], // responses + [{ declaredAge: 18 }] // user preferences (has declared age) + ) const response = await app.inject({ method: 'GET', @@ -604,7 +608,11 @@ describe('onboarding user routes', () => { it('returns complete=false when mandatory field not answered', async () => { const field = sampleField({ isMandatory: true }) - queueSelectResults([field], []) // fields, no responses + queueSelectResults( + [field], // fields + [], // no responses + [{ declaredAge: 18 }] // user preferences (has declared age) + ) const response = await app.inject({ method: 'GET', @@ -620,7 +628,11 @@ describe('onboarding user routes', () => { it('returns complete=true when all mandatory fields answered', async () => { const field = sampleField({ isMandatory: true }) - queueSelectResults([field], [sampleResponse()]) + queueSelectResults( + [field], // fields + [sampleResponse()], // responses + [{ declaredAge: 18 }] // user preferences (has declared age) + ) const response = await app.inject({ method: 'GET', @@ -646,7 +658,8 @@ describe('onboarding user routes', () => { // Only mandatory field answered queueSelectResults( [mandatoryField, optionalField], - [sampleResponse({ fieldId: 'field-001' })] + [sampleResponse({ fieldId: 'field-001' })], + [{ declaredAge: 18 }] // user preferences (has declared age) ) const response = await app.inject({ diff --git a/tests/unit/routes/reactions.test.ts b/tests/unit/routes/reactions.test.ts index 8d97c3b..ac8ab34 100644 --- a/tests/unit/routes/reactions.test.ts +++ b/tests/unit/routes/reactions.test.ts @@ -29,6 +29,12 @@ vi.mock('../../../src/lib/pds-client.js', () => ({ }), })) +// Mock onboarding gate (tested separately in onboarding-gate.test.ts) +const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) +vi.mock('../../../src/lib/onboarding-gate.js', () => ({ + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, +})) + // Import routes AFTER mocking import { reactionRoutes } from '../../../src/routes/reactions.js' @@ -227,8 +233,6 @@ describe('reaction routes', () => { }) it('creates a reaction on a topic and returns 201', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // 1. Community settings query -> reactionSet includes "like" selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like', 'heart'] }]) // 2. Subject existence check -> topic found @@ -272,8 +276,6 @@ describe('reaction routes', () => { }) it('creates a reaction on a reply and returns 201', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // 1. Community settings selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) // 2. Subject existence check -> reply found @@ -305,8 +307,6 @@ describe('reaction routes', () => { isTrackedFn.mockResolvedValue(false) trackRepoFn.mockResolvedValue(undefined) - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) @@ -396,8 +396,6 @@ describe('reaction routes', () => { }) it("returns 400 when reaction type is not in community's reaction set", async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // Community only allows "like" selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) @@ -416,8 +414,6 @@ describe('reaction routes', () => { }) it("uses default reaction set ['like'] when no settings exist", async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // No settings row found selectChain.where.mockResolvedValueOnce([]) // Subject exists @@ -439,8 +435,6 @@ describe('reaction routes', () => { }) it('returns 404 when subject does not exist', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) // Subject not found selectChain.where.mockResolvedValueOnce([]) @@ -460,8 +454,6 @@ describe('reaction routes', () => { }) it('returns 404 when subject URI has unknown collection', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) // Unknown collection -> subjectExists stays false @@ -480,8 +472,6 @@ describe('reaction routes', () => { }) it('returns 409 when duplicate reaction (unique constraint)', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // onConflictDoNothing -> returning() returns empty array @@ -502,8 +492,6 @@ describe('reaction routes', () => { }) it('returns 502 when PDS write fails', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) diff --git a/tests/unit/routes/replies.test.ts b/tests/unit/routes/replies.test.ts index dde3c92..23e1a61 100644 --- a/tests/unit/routes/replies.test.ts +++ b/tests/unit/routes/replies.test.ts @@ -66,6 +66,12 @@ import { isNewAccount as isNewAccountMock, } from '../../../src/lib/anti-spam.js' +// Mock onboarding gate (tested separately in onboarding-gate.test.ts) +const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) +vi.mock('../../../src/lib/onboarding-gate.js', () => ({ + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, +})) + // Import routes AFTER mocking import { replyRoutes } from '../../../src/routes/replies.js' @@ -387,8 +393,6 @@ describe('reply routes', () => { it('creates a threaded reply (with parentUri) and returns 201', async () => { // First select: look up topic selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - // Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // Second select: look up parent reply selectChain.where.mockResolvedValueOnce([ sampleReplyRow({ @@ -1492,18 +1496,11 @@ describe('reply routes', () => { it('returns 403 when onboarding is incomplete', async () => { // Topic lookup selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - // Onboarding: mandatory fields exist - selectChain.where.mockResolvedValueOnce([ - { - id: 'field1', - label: 'Accept Rules', - fieldType: 'checkbox', - isMandatory: true, - communityDid: 'did:plc:community123', - }, - ]) - // Onboarding: user responses (none) - selectChain.where.mockResolvedValueOnce([]) + // Override onboarding gate to return incomplete + checkOnboardingCompleteFn.mockResolvedValueOnce({ + complete: false, + missingFields: [{ id: 'field1', label: 'Accept Rules', fieldType: 'checkbox' }], + }) const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) const response = await app.inject({ diff --git a/tests/unit/routes/topics-replies-integration.test.ts b/tests/unit/routes/topics-replies-integration.test.ts index 73ff06c..ac2e838 100644 --- a/tests/unit/routes/topics-replies-integration.test.ts +++ b/tests/unit/routes/topics-replies-integration.test.ts @@ -59,6 +59,11 @@ vi.mock('../../../src/lib/anti-spam.js', () => ({ runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), })) +// Mock onboarding gate (tested separately in onboarding-gate.test.ts) +vi.mock('../../../src/lib/onboarding-gate.js', () => ({ + checkOnboardingComplete: vi.fn().mockResolvedValue({ complete: true, missingFields: [] }), +})) + // Import routes AFTER mocking import { topicRoutes } from '../../../src/routes/topics.js' import { replyRoutes } from '../../../src/routes/replies.js' @@ -637,8 +642,6 @@ describe('topics + replies cross-endpoint integration', () => { // Topic lookup succeeds selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) - // Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // Parent reply lookup succeeds selectChain.where.mockResolvedValueOnce([ sampleReplyRow({ diff --git a/tests/unit/routes/votes.test.ts b/tests/unit/routes/votes.test.ts index 66b7099..3e75ef3 100644 --- a/tests/unit/routes/votes.test.ts +++ b/tests/unit/routes/votes.test.ts @@ -29,6 +29,12 @@ vi.mock('../../../src/lib/pds-client.js', () => ({ }), })) +// Mock onboarding gate (tested separately in onboarding-gate.test.ts) +const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) +vi.mock('../../../src/lib/onboarding-gate.js', () => ({ + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, +})) + // Import routes AFTER mocking import { voteRoutes } from '../../../src/routes/votes.js' @@ -222,8 +228,6 @@ describe('vote routes', () => { }) it('creates a vote on a topic and returns 201', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // 1. Subject existence check -> topic found selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // 2. Insert returning @@ -265,8 +269,6 @@ describe('vote routes', () => { }) it('creates a vote on a reply and returns 201', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // 1. Subject existence check -> reply found selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]) // 2. Insert returning @@ -296,8 +298,6 @@ describe('vote routes', () => { isTrackedFn.mockResolvedValue(false) trackRepoFn.mockResolvedValue(undefined) - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) insertChain.returning.mockResolvedValueOnce([sampleVoteRow()]) @@ -360,9 +360,6 @@ describe('vote routes', () => { }) it('returns 400 for invalid direction', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) - const response = await app.inject({ method: 'POST', url: '/api/votes', @@ -389,8 +386,6 @@ describe('vote routes', () => { }) it('returns 404 when subject does not exist', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) // Subject not found selectChain.where.mockResolvedValueOnce([]) @@ -409,9 +404,6 @@ describe('vote routes', () => { }) it('returns 404 when subject URI has unknown collection', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) - const response = await app.inject({ method: 'POST', url: '/api/votes', @@ -427,8 +419,6 @@ describe('vote routes', () => { }) it('returns 409 when duplicate vote (unique constraint)', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) // onConflictDoNothing -> returning() returns empty array insertChain.returning.mockResolvedValueOnce([]) @@ -448,8 +438,6 @@ describe('vote routes', () => { }) it('returns 502 when PDS write fails', async () => { - // 0. Onboarding gate: no mandatory fields - selectChain.where.mockResolvedValueOnce([]) selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable'))