Barazo AppView backend barazo.forum

feat: handle-based blocking and system age onboarding (#111)

* feat(profiles,onboarding): handle-based blocking and system age onboarding

Add handle resolution endpoint and enrich preferences with blocked user
profiles so the frontend can display handles instead of raw DIDs.

- GET /api/users/resolve-handles: resolve handles to profiles via DB
lookup with Bluesky public API fallback
- GET /api/users/me/preferences: include blockedProfiles in response
- GET /api/users/me/preferences/communities: include blockedProfiles
per community with batch resolution
- GET /api/onboarding/status: inject system-level age_confirmation
field when user has no declaredAge and no admin age field exists
- POST /api/onboarding/submit: sync age_confirmation responses to
user_preferences and users tables
- checkOnboardingComplete: include system age field in completeness
check

Closes barazo-forum/barazo-workspace#59
Closes barazo-forum/barazo-workspace#60

* fix(tests): update mocks for onboarding gate system age field queries

The checkOnboardingComplete function was extended with two new DB queries
(all community fields for age_confirmation check, and user preferences
for declaredAge). Test mocks needed updating:

- onboarding-gate.test.ts: queue 4 select results instead of 1-2 to
cover the new allCommunityFields + userPreferences queries
- onboarding.test.ts: queue 3rd select result for the userPreferences
query added to GET /api/onboarding/status
- reactions, votes, replies, integration tests: mock checkOnboardingComplete
at module level (matching the pattern in topics.test.ts) instead of
fragile inline DB mock ordering

authored by

Guido X Jansen and committed by
GitHub
7ffe8e18 92763077

+431 -82
+31 -4
src/lib/onboarding-gate.ts
··· 3 3 communityOnboardingFields, 4 4 userOnboardingResponses, 5 5 } from '../db/schema/onboarding-fields.js' 6 + import { userPreferences } from '../db/schema/user-preferences.js' 6 7 import type { Database } from '../db/index.js' 8 + 9 + const SYSTEM_AGE_FIELD_ID = 'system-age-confirmation' 7 10 8 11 export interface OnboardingCheckResult { 9 12 complete: boolean ··· 14 17 * Check whether a user has completed all mandatory onboarding fields 15 18 * for a community. Returns complete=true if no fields are configured 16 19 * or all mandatory ones have responses. 20 + * 21 + * Also checks for the system-level age declaration: if no admin-configured 22 + * age_confirmation field exists and the user has no declaredAge, the system 23 + * age field is treated as a missing mandatory field. 17 24 */ 18 25 export async function checkOnboardingComplete( 19 26 db: Database, ··· 31 38 ) 32 39 ) 33 40 34 - if (fields.length === 0) { 35 - return { complete: true, missingFields: [] } 36 - } 37 - 38 41 // Get user's responses for this community 39 42 const responses = await db 40 43 .select() ··· 51 54 const missingFields = fields 52 55 .filter((f) => !answeredFieldIds.has(f.id)) 53 56 .map((f) => ({ id: f.id, label: f.label, fieldType: f.fieldType })) 57 + 58 + // Check for system-level age field: inject if no admin age field and user has no declaredAge 59 + const allCommunityFields = await db 60 + .select({ fieldType: communityOnboardingFields.fieldType }) 61 + .from(communityOnboardingFields) 62 + .where(eq(communityOnboardingFields.communityDid, communityDid)) 63 + 64 + const hasAdminAgeField = allCommunityFields.some((f) => f.fieldType === 'age_confirmation') 65 + 66 + if (!hasAdminAgeField) { 67 + const prefRows = await db 68 + .select({ declaredAge: userPreferences.declaredAge }) 69 + .from(userPreferences) 70 + .where(eq(userPreferences.did, did)) 71 + 72 + const declaredAge = prefRows[0]?.declaredAge ?? null 73 + if (declaredAge === null) { 74 + missingFields.unshift({ 75 + id: SYSTEM_AGE_FIELD_ID, 76 + label: 'Age Declaration', 77 + fieldType: 'age_confirmation', 78 + }) 79 + } 80 + } 54 81 55 82 return { 56 83 complete: missingFields.length === 0,
+133 -7
src/routes/onboarding.ts
··· 14 14 communityOnboardingFields, 15 15 userOnboardingResponses, 16 16 } from '../db/schema/onboarding-fields.js' 17 + import { userPreferences } from '../db/schema/user-preferences.js' 18 + import { users } from '../db/schema/users.js' 19 + import { ageDeclarationSchema } from '../validation/profiles.js' 17 20 18 21 // --------------------------------------------------------------------------- 19 22 // OpenAPI JSON Schema definitions ··· 52 55 }, 53 56 }, 54 57 } 58 + 59 + // --------------------------------------------------------------------------- 60 + // Constants 61 + // --------------------------------------------------------------------------- 62 + 63 + const SYSTEM_AGE_FIELD_ID = 'system-age-confirmation' 55 64 56 65 // --------------------------------------------------------------------------- 57 66 // Helpers ··· 444 453 445 454 const responseMap = new Map(responses.map((r) => [r.fieldId, r.response])) 446 455 447 - const fieldsWithStatus = fields.map((field) => ({ 456 + // Check if we need to inject a system-level age_confirmation field 457 + const hasAdminAgeField = fields.some((f) => f.fieldType === 'age_confirmation') 458 + 459 + // Look up user's declared age 460 + const prefRows = await db 461 + .select({ declaredAge: userPreferences.declaredAge }) 462 + .from(userPreferences) 463 + .where(eq(userPreferences.did, user.did)) 464 + 465 + const declaredAge = prefRows[0]?.declaredAge ?? null 466 + const needsSystemAgeField = !hasAdminAgeField && declaredAge === null 467 + 468 + type FieldWithStatus = { 469 + id: string 470 + communityDid: string 471 + fieldType: string 472 + label: string 473 + description: string | null 474 + isMandatory: boolean 475 + sortOrder: number 476 + config: Record<string, unknown> | null 477 + createdAt: string 478 + updatedAt: string 479 + completed: boolean 480 + response: unknown 481 + } 482 + 483 + const fieldsWithStatus: FieldWithStatus[] = fields.map((field) => ({ 448 484 ...serializeField(field), 449 485 completed: responseMap.has(field.id), 450 486 response: responseMap.get(field.id) ?? null, 451 487 })) 452 488 453 - const complete = fields.filter((f) => f.isMandatory).every((f) => responseMap.has(f.id)) 489 + // Inject system age field at the beginning if needed 490 + if (needsSystemAgeField) { 491 + const now = new Date().toISOString() 492 + fieldsWithStatus.unshift({ 493 + id: SYSTEM_AGE_FIELD_ID, 494 + communityDid, 495 + fieldType: 'age_confirmation', 496 + label: 'Age Declaration', 497 + description: 498 + 'Please select your age bracket. This determines which content is available to you.', 499 + isMandatory: true, 500 + sortOrder: -1, 501 + config: null, 502 + createdAt: now, 503 + updatedAt: now, 504 + completed: false, 505 + response: null, 506 + }) 507 + } 508 + 509 + // Check completeness: all mandatory fields (including system age field) must be answered 510 + const mandatoryFieldsComplete = fields 511 + .filter((f) => f.isMandatory) 512 + .every((f) => responseMap.has(f.id)) 513 + const complete = mandatoryFieldsComplete && !needsSystemAgeField 454 514 455 515 return reply.status(200).send({ 456 516 complete, ··· 516 576 517 577 const fieldMap = new Map(fields.map((f) => [f.id, f])) 518 578 519 - // Validate each response 579 + // Separate system-level age submissions from admin-configured ones 580 + const systemAgeSubmission = parsed.data.find((s) => s.fieldId === SYSTEM_AGE_FIELD_ID) 581 + const adminSubmissions = parsed.data.filter((s) => s.fieldId !== SYSTEM_AGE_FIELD_ID) 582 + 583 + // Validate admin-configured field responses 520 584 const errors: string[] = [] 521 - for (const submission of parsed.data) { 585 + for (const submission of adminSubmissions) { 522 586 const field = fieldMap.get(submission.fieldId) 523 587 if (!field) { 524 588 errors.push(`Unknown field: ${submission.fieldId}`) ··· 531 595 } 532 596 } 533 597 598 + // Validate system age submission 599 + if (systemAgeSubmission) { 600 + const ageParsed = ageDeclarationSchema.safeParse({ 601 + declaredAge: systemAgeSubmission.response, 602 + }) 603 + if (!ageParsed.success) { 604 + errors.push('Age Declaration: must be one of 0, 13, 14, 15, 16, 18') 605 + } 606 + } 607 + 608 + // Also validate admin-configured age_confirmation fields the same way 609 + for (const submission of adminSubmissions) { 610 + const field = fieldMap.get(submission.fieldId) 611 + if (field?.fieldType === 'age_confirmation') { 612 + const ageParsed = ageDeclarationSchema.safeParse({ 613 + declaredAge: submission.response, 614 + }) 615 + if (!ageParsed.success) { 616 + errors.push(`${field.label}: must be one of 0, 13, 14, 15, 16, 18`) 617 + } 618 + } 619 + } 620 + 534 621 if (errors.length > 0) { 535 622 throw badRequest(errors.join('; ')) 536 623 } 537 624 538 - // Upsert responses (idempotent) 539 - for (const submission of parsed.data) { 625 + // Upsert admin-field responses (idempotent) 626 + for (const submission of adminSubmissions) { 540 627 await db 541 628 .insert(userOnboardingResponses) 542 629 .values({ ··· 556 643 completedAt: new Date(), 557 644 }, 558 645 }) 646 + 647 + // Sync age_confirmation to user preferences 648 + const field = fieldMap.get(submission.fieldId) 649 + if (field?.fieldType === 'age_confirmation' && typeof submission.response === 'number') { 650 + const now = new Date() 651 + await db 652 + .insert(userPreferences) 653 + .values({ did: user.did, declaredAge: submission.response, updatedAt: now }) 654 + .onConflictDoUpdate({ 655 + target: userPreferences.did, 656 + set: { declaredAge: submission.response, updatedAt: now }, 657 + }) 658 + await db 659 + .update(users) 660 + .set({ declaredAge: submission.response }) 661 + .where(eq(users.did, user.did)) 662 + } 663 + } 664 + 665 + // Handle system-level age submission (syncs to user_preferences + users) 666 + if (systemAgeSubmission && typeof systemAgeSubmission.response === 'number') { 667 + const declaredAge = systemAgeSubmission.response 668 + const now = new Date() 669 + 670 + await db 671 + .insert(userPreferences) 672 + .values({ did: user.did, declaredAge, updatedAt: now }) 673 + .onConflictDoUpdate({ 674 + target: userPreferences.did, 675 + set: { declaredAge, updatedAt: now }, 676 + }) 677 + 678 + await db.update(users).set({ declaredAge }).where(eq(users.did, user.did)) 559 679 } 560 680 561 681 // Check completeness (all mandatory fields answered?) ··· 570 690 ) 571 691 572 692 const answeredFieldIds = new Set(existingResponses.map((r) => r.fieldId)) 573 - const complete = fields 693 + const adminFieldsComplete = fields 574 694 .filter((f) => f.isMandatory) 575 695 .every((f) => answeredFieldIds.has(f.id)) 696 + 697 + // System age field counts as complete if user now has a declaredAge 698 + const systemAgeComplete = systemAgeSubmission 699 + ? typeof systemAgeSubmission.response === 'number' 700 + : true 701 + const complete = adminFieldsComplete && systemAgeComplete 576 702 577 703 app.log.info( 578 704 {
+172 -10
src/routes/profiles.ts
··· 1 - import { eq, and, sql } from 'drizzle-orm' 1 + import { eq, and, sql, inArray } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { 5 5 userPreferencesSchema, 6 6 communityPreferencesSchema, 7 7 ageDeclarationSchema, 8 + resolveHandlesSchema, 8 9 } from '../validation/profiles.js' 9 10 import { users } from '../db/schema/users.js' 10 11 import { communityProfiles } from '../db/schema/community-profiles.js' ··· 22 23 import { sybilClusters } from '../db/schema/sybil-clusters.js' 23 24 import { interactionGraph } from '../db/schema/interaction-graph.js' 24 25 import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' 26 + import { resolveAuthors, type AuthorProfile } from '../lib/resolve-authors.js' 27 + import type { Database } from '../db/index.js' 25 28 26 29 // --------------------------------------------------------------------------- 27 30 // OpenAPI JSON Schema definitions ··· 95 98 }, 96 99 } 97 100 101 + const authorProfileJsonSchema = { 102 + type: 'object' as const, 103 + properties: { 104 + did: { type: 'string' as const }, 105 + handle: { type: 'string' as const }, 106 + displayName: { type: ['string', 'null'] as const }, 107 + avatarUrl: { type: ['string', 'null'] as const }, 108 + }, 109 + } 110 + 98 111 const preferencesJsonSchema = { 99 112 type: 'object' as const, 100 113 properties: { ··· 106 119 blockedDids: { 107 120 type: 'array' as const, 108 121 items: { type: 'string' as const }, 122 + }, 123 + blockedProfiles: { 124 + type: 'array' as const, 125 + items: authorProfileJsonSchema, 109 126 }, 110 127 mutedDids: { type: 'array' as const, items: { type: 'string' as const } }, 111 128 crossPostBluesky: { type: 'boolean' as const }, ··· 175 192 } 176 193 } 177 194 195 + /** Resolve a list of DIDs to AuthorProfile[], preserving order. */ 196 + async function resolveBlockedProfiles(dids: string[], db: Database): Promise<AuthorProfile[]> { 197 + if (dids.length === 0) return [] 198 + const profileMap = await resolveAuthors(dids, null, db) 199 + return dids.map( 200 + (did) => 201 + profileMap.get(did) ?? { 202 + did, 203 + handle: did, 204 + displayName: null, 205 + avatarUrl: null, 206 + } 207 + ) 208 + } 209 + 178 210 // --------------------------------------------------------------------------- 179 211 // Profile routes plugin 180 212 // --------------------------------------------------------------------------- ··· 197 229 const { db, authMiddleware } = app 198 230 199 231 // ------------------------------------------------------------------- 232 + // GET /api/users/resolve-handles (auth required) 233 + // ------------------------------------------------------------------- 234 + 235 + app.get( 236 + '/api/users/resolve-handles', 237 + { 238 + preHandler: [authMiddleware.requireAuth], 239 + schema: { 240 + tags: ['Profiles'], 241 + summary: 'Resolve handles to user profiles', 242 + security: [{ bearerAuth: [] }], 243 + querystring: { 244 + type: 'object', 245 + required: ['handles'], 246 + properties: { 247 + handles: { type: 'string' }, 248 + }, 249 + }, 250 + response: { 251 + 200: { 252 + type: 'object' as const, 253 + properties: { 254 + users: { 255 + type: 'array' as const, 256 + items: authorProfileJsonSchema, 257 + }, 258 + }, 259 + }, 260 + 400: errorResponseSchema, 261 + 401: errorResponseSchema, 262 + }, 263 + }, 264 + }, 265 + async (request, reply) => { 266 + const requestUser = request.user 267 + if (!requestUser) { 268 + return reply.status(401).send({ error: 'Authentication required' }) 269 + } 270 + 271 + const parsed = resolveHandlesSchema.safeParse(request.query) 272 + if (!parsed.success) { 273 + throw badRequest('handles query parameter is required (comma-separated, max 25)') 274 + } 275 + 276 + const handles = parsed.data.handles 277 + 278 + // Look up handles in our users table 279 + const userRows = await db 280 + .select({ 281 + did: users.did, 282 + handle: users.handle, 283 + displayName: users.displayName, 284 + avatarUrl: users.avatarUrl, 285 + }) 286 + .from(users) 287 + .where(inArray(users.handle, handles)) 288 + 289 + const foundMap = new Map<string, AuthorProfile>() 290 + for (const row of userRows) { 291 + foundMap.set(row.handle, { 292 + did: row.did, 293 + handle: row.handle, 294 + displayName: row.displayName, 295 + avatarUrl: row.avatarUrl, 296 + }) 297 + } 298 + 299 + // For handles not found locally, try AT Protocol identity resolution 300 + // via the public Bluesky AppView XRPC endpoint 301 + const missingHandles = handles.filter((h) => !foundMap.has(h)) 302 + for (const handle of missingHandles) { 303 + try { 304 + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 305 + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) 306 + if (res.ok) { 307 + const data = (await res.json()) as { did?: string } 308 + if (data.did) { 309 + foundMap.set(handle, { 310 + did: data.did, 311 + handle, 312 + displayName: null, 313 + avatarUrl: null, 314 + }) 315 + } 316 + } 317 + } catch { 318 + // Handle not resolvable -- skip silently 319 + } 320 + } 321 + 322 + // Return in request order 323 + const resolved: AuthorProfile[] = handles 324 + .map((h) => foundMap.get(h)) 325 + .filter((p): p is AuthorProfile => p !== undefined) 326 + 327 + return reply.status(200).send({ users: resolved }) 328 + } 329 + ) 330 + 331 + // ------------------------------------------------------------------- 200 332 // GET /api/users/:handle (public, optionalAuth) 201 333 // ------------------------------------------------------------------- 202 334 ··· 718 850 719 851 const prefs = rows[0] 720 852 if (!prefs) { 721 - return reply.status(200).send(defaultPreferences()) 853 + return reply.status(200).send({ ...defaultPreferences(), blockedProfiles: [] }) 722 854 } 855 + 856 + const blockedProfiles = await resolveBlockedProfiles(prefs.blockedDids, db) 723 857 724 858 return reply.status(200).send({ 725 859 maturityLevel: prefs.maturityLevel, 726 860 declaredAge: prefs.declaredAge ?? null, 727 861 mutedWords: prefs.mutedWords, 728 862 blockedDids: prefs.blockedDids, 863 + blockedProfiles, 729 864 mutedDids: prefs.mutedDids, 730 865 crossPostBluesky: prefs.crossPostBluesky, 731 866 crossPostFrontpage: prefs.crossPostFrontpage, ··· 827 962 828 963 const prefs = rows[0] 829 964 if (!prefs) { 830 - return reply.status(200).send(defaultPreferences()) 965 + return reply.status(200).send({ ...defaultPreferences(), blockedProfiles: [] }) 831 966 } 967 + 968 + const updatedBlockedProfiles = await resolveBlockedProfiles(prefs.blockedDids, db) 832 969 833 970 return reply.status(200).send({ 834 971 maturityLevel: prefs.maturityLevel, 835 972 declaredAge: prefs.declaredAge ?? null, 836 973 mutedWords: prefs.mutedWords, 837 974 blockedDids: prefs.blockedDids, 975 + blockedProfiles: updatedBlockedProfiles, 838 976 mutedDids: prefs.mutedDids, 839 977 crossPostBluesky: prefs.crossPostBluesky, 840 978 crossPostFrontpage: prefs.crossPostFrontpage, ··· 875 1013 type: 'array' as const, 876 1014 items: { type: 'string' as const }, 877 1015 }, 1016 + blockedProfiles: { 1017 + type: 'array' as const, 1018 + items: authorProfileJsonSchema, 1019 + }, 878 1020 }, 879 1021 }, 880 1022 }, ··· 905 1047 ) 906 1048 .where(eq(userCommunityPreferences.did, requestUser.did)) 907 1049 908 - const communities = rows.map((row) => ({ 909 - communityDid: row.communityDid, 910 - communityName: row.communityName ?? row.communityDid, 911 - maturityLevel: row.maturityOverride ?? 'inherit', 912 - mutedWords: row.mutedWords ?? [], 913 - blockedDids: row.blockedDids ?? [], 914 - })) 1050 + // Collect all blocked DIDs across communities for batch resolution 1051 + const allBlockedDids = rows.flatMap((row) => row.blockedDids ?? []) 1052 + const profileMap = 1053 + allBlockedDids.length > 0 1054 + ? await resolveAuthors([...new Set(allBlockedDids)], null, db) 1055 + : new Map<string, AuthorProfile>() 1056 + 1057 + const communities = rows.map((row) => { 1058 + const dids = row.blockedDids ?? [] 1059 + return { 1060 + communityDid: row.communityDid, 1061 + communityName: row.communityName ?? row.communityDid, 1062 + maturityLevel: row.maturityOverride ?? 'inherit', 1063 + mutedWords: row.mutedWords ?? [], 1064 + blockedDids: dids, 1065 + blockedProfiles: dids.map( 1066 + (did) => 1067 + profileMap.get(did) ?? { 1068 + did, 1069 + handle: did, 1070 + displayName: null, 1071 + avatarUrl: null, 1072 + } 1073 + ), 1074 + } 1075 + }) 915 1076 916 1077 // Always include the current community so the settings page shows it 917 1078 // even if the user has never saved per-community preferences. ··· 932 1093 maturityLevel: 'inherit', 933 1094 mutedWords: [], 934 1095 blockedDids: [], 1096 + blockedProfiles: [], 935 1097 }) 936 1098 } 937 1099
+20
src/validation/profiles.ts
··· 50 50 }) 51 51 52 52 export type AgeDeclarationInput = z.infer<typeof ageDeclarationSchema> 53 + 54 + // --------------------------------------------------------------------------- 55 + // Query schemas 56 + // --------------------------------------------------------------------------- 57 + 58 + /** Schema for GET /api/users/resolve-handles query string. */ 59 + export const resolveHandlesSchema = z.object({ 60 + handles: z 61 + .string() 62 + .min(1) 63 + .transform((val) => 64 + val 65 + .split(',') 66 + .map((h) => h.trim()) 67 + .filter(Boolean) 68 + ) 69 + .pipe(z.array(z.string().min(1)).min(1).max(25)), 70 + }) 71 + 72 + export type ResolveHandlesInput = z.infer<typeof resolveHandlesSchema>
+30 -5
tests/unit/lib/onboarding-gate.test.ts
··· 61 61 }) 62 62 63 63 it('returns complete=true when community has no onboarding fields', async () => { 64 - queueSelectResults([]) // no mandatory fields 64 + queueSelectResults( 65 + [], // no mandatory fields 66 + [], // no user responses 67 + [], // no community fields at all (no admin age field) 68 + [{ declaredAge: 18 }] // user has declared age 69 + ) 65 70 66 71 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 67 72 ··· 71 76 72 77 it('returns complete=true when user has completed all mandatory fields', async () => { 73 78 const field = sampleField() 74 - queueSelectResults([field], [sampleResponse()]) 79 + queueSelectResults( 80 + [field], // mandatory fields 81 + [sampleResponse()], // user responses 82 + [], // no community fields with age_confirmation 83 + [{ declaredAge: 18 }] // user has declared age 84 + ) 75 85 76 86 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 77 87 ··· 81 91 82 92 it("returns complete=false with missing fields when user hasn't completed mandatory fields", async () => { 83 93 const field = sampleField() 84 - queueSelectResults([field], []) // no responses 94 + queueSelectResults( 95 + [field], // mandatory fields 96 + [], // no responses 97 + [], // no community fields with age_confirmation 98 + [{ declaredAge: 18 }] // user has declared age (age check passes) 99 + ) 85 100 86 101 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 87 102 ··· 96 111 const field2 = sampleField({ id: 'field-002', label: 'ToS', fieldType: 'tos_acceptance' }) 97 112 98 113 // Only field-001 answered 99 - queueSelectResults([field1, field2], [sampleResponse({ fieldId: 'field-001' })]) 114 + queueSelectResults( 115 + [field1, field2], // mandatory fields 116 + [sampleResponse({ fieldId: 'field-001' })], // only one response 117 + [], // no community fields with age_confirmation 118 + [{ declaredAge: 18 }] // user has declared age 119 + ) 100 120 101 121 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 102 122 ··· 107 127 108 128 it('only checks mandatory fields (ignores optional)', async () => { 109 129 // Only query returns mandatory fields, so optional are not fetched 110 - queueSelectResults([], []) // no mandatory fields, no responses 130 + queueSelectResults( 131 + [], // no mandatory fields 132 + [], // no responses 133 + [], // no community fields with age_confirmation 134 + [{ declaredAge: 18 }] // user has declared age 135 + ) 111 136 112 137 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 113 138
+17 -4
tests/unit/routes/onboarding.test.ts
··· 588 588 }) 589 589 590 590 it('returns complete=true when no onboarding fields exist', async () => { 591 - queueSelectResults([], []) // fields, responses 591 + queueSelectResults( 592 + [], // fields 593 + [], // responses 594 + [{ declaredAge: 18 }] // user preferences (has declared age) 595 + ) 592 596 593 597 const response = await app.inject({ 594 598 method: 'GET', ··· 604 608 605 609 it('returns complete=false when mandatory field not answered', async () => { 606 610 const field = sampleField({ isMandatory: true }) 607 - queueSelectResults([field], []) // fields, no responses 611 + queueSelectResults( 612 + [field], // fields 613 + [], // no responses 614 + [{ declaredAge: 18 }] // user preferences (has declared age) 615 + ) 608 616 609 617 const response = await app.inject({ 610 618 method: 'GET', ··· 620 628 621 629 it('returns complete=true when all mandatory fields answered', async () => { 622 630 const field = sampleField({ isMandatory: true }) 623 - queueSelectResults([field], [sampleResponse()]) 631 + queueSelectResults( 632 + [field], // fields 633 + [sampleResponse()], // responses 634 + [{ declaredAge: 18 }] // user preferences (has declared age) 635 + ) 624 636 625 637 const response = await app.inject({ 626 638 method: 'GET', ··· 646 658 // Only mandatory field answered 647 659 queueSelectResults( 648 660 [mandatoryField, optionalField], 649 - [sampleResponse({ fieldId: 'field-001' })] 661 + [sampleResponse({ fieldId: 'field-001' })], 662 + [{ declaredAge: 18 }] // user preferences (has declared age) 650 663 ) 651 664 652 665 const response = await app.inject({
+6 -18
tests/unit/routes/reactions.test.ts
··· 29 29 }), 30 30 })) 31 31 32 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 33 + const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) 34 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 35 + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, 36 + })) 37 + 32 38 // Import routes AFTER mocking 33 39 import { reactionRoutes } from '../../../src/routes/reactions.js' 34 40 ··· 227 233 }) 228 234 229 235 it('creates a reaction on a topic and returns 201', async () => { 230 - // 0. Onboarding gate: no mandatory fields 231 - selectChain.where.mockResolvedValueOnce([]) 232 236 // 1. Community settings query -> reactionSet includes "like" 233 237 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like', 'heart'] }]) 234 238 // 2. Subject existence check -> topic found ··· 272 276 }) 273 277 274 278 it('creates a reaction on a reply and returns 201', async () => { 275 - // 0. Onboarding gate: no mandatory fields 276 - selectChain.where.mockResolvedValueOnce([]) 277 279 // 1. Community settings 278 280 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 279 281 // 2. Subject existence check -> reply found ··· 305 307 isTrackedFn.mockResolvedValue(false) 306 308 trackRepoFn.mockResolvedValue(undefined) 307 309 308 - // 0. Onboarding gate: no mandatory fields 309 - selectChain.where.mockResolvedValueOnce([]) 310 310 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 311 311 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 312 312 insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) ··· 396 396 }) 397 397 398 398 it("returns 400 when reaction type is not in community's reaction set", async () => { 399 - // 0. Onboarding gate: no mandatory fields 400 - selectChain.where.mockResolvedValueOnce([]) 401 399 // Community only allows "like" 402 400 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 403 401 ··· 416 414 }) 417 415 418 416 it("uses default reaction set ['like'] when no settings exist", async () => { 419 - // 0. Onboarding gate: no mandatory fields 420 - selectChain.where.mockResolvedValueOnce([]) 421 417 // No settings row found 422 418 selectChain.where.mockResolvedValueOnce([]) 423 419 // Subject exists ··· 439 435 }) 440 436 441 437 it('returns 404 when subject does not exist', async () => { 442 - // 0. Onboarding gate: no mandatory fields 443 - selectChain.where.mockResolvedValueOnce([]) 444 438 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 445 439 // Subject not found 446 440 selectChain.where.mockResolvedValueOnce([]) ··· 460 454 }) 461 455 462 456 it('returns 404 when subject URI has unknown collection', async () => { 463 - // 0. Onboarding gate: no mandatory fields 464 - selectChain.where.mockResolvedValueOnce([]) 465 457 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 466 458 // Unknown collection -> subjectExists stays false 467 459 ··· 480 472 }) 481 473 482 474 it('returns 409 when duplicate reaction (unique constraint)', async () => { 483 - // 0. Onboarding gate: no mandatory fields 484 - selectChain.where.mockResolvedValueOnce([]) 485 475 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 486 476 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 487 477 // onConflictDoNothing -> returning() returns empty array ··· 502 492 }) 503 493 504 494 it('returns 502 when PDS write fails', async () => { 505 - // 0. Onboarding gate: no mandatory fields 506 - selectChain.where.mockResolvedValueOnce([]) 507 495 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 508 496 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 509 497 createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable'))
+11 -14
tests/unit/routes/replies.test.ts
··· 66 66 isNewAccount as isNewAccountMock, 67 67 } from '../../../src/lib/anti-spam.js' 68 68 69 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 70 + const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) 71 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 72 + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, 73 + })) 74 + 69 75 // Import routes AFTER mocking 70 76 import { replyRoutes } from '../../../src/routes/replies.js' 71 77 ··· 387 393 it('creates a threaded reply (with parentUri) and returns 201', async () => { 388 394 // First select: look up topic 389 395 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 390 - // Onboarding gate: no mandatory fields 391 - selectChain.where.mockResolvedValueOnce([]) 392 396 // Second select: look up parent reply 393 397 selectChain.where.mockResolvedValueOnce([ 394 398 sampleReplyRow({ ··· 1492 1496 it('returns 403 when onboarding is incomplete', async () => { 1493 1497 // Topic lookup 1494 1498 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 1495 - // Onboarding: mandatory fields exist 1496 - selectChain.where.mockResolvedValueOnce([ 1497 - { 1498 - id: 'field1', 1499 - label: 'Accept Rules', 1500 - fieldType: 'checkbox', 1501 - isMandatory: true, 1502 - communityDid: 'did:plc:community123', 1503 - }, 1504 - ]) 1505 - // Onboarding: user responses (none) 1506 - selectChain.where.mockResolvedValueOnce([]) 1499 + // Override onboarding gate to return incomplete 1500 + checkOnboardingCompleteFn.mockResolvedValueOnce({ 1501 + complete: false, 1502 + missingFields: [{ id: 'field1', label: 'Accept Rules', fieldType: 'checkbox' }], 1503 + }) 1507 1504 1508 1505 const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) 1509 1506 const response = await app.inject({
+5 -2
tests/unit/routes/topics-replies-integration.test.ts
··· 59 59 runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), 60 60 })) 61 61 62 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 63 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 64 + checkOnboardingComplete: vi.fn().mockResolvedValue({ complete: true, missingFields: [] }), 65 + })) 66 + 62 67 // Import routes AFTER mocking 63 68 import { topicRoutes } from '../../../src/routes/topics.js' 64 69 import { replyRoutes } from '../../../src/routes/replies.js' ··· 637 642 638 643 // Topic lookup succeeds 639 644 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 640 - // Onboarding gate: no mandatory fields 641 - selectChain.where.mockResolvedValueOnce([]) 642 645 // Parent reply lookup succeeds 643 646 selectChain.where.mockResolvedValueOnce([ 644 647 sampleReplyRow({
+6 -18
tests/unit/routes/votes.test.ts
··· 29 29 }), 30 30 })) 31 31 32 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 33 + const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) 34 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 35 + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, 36 + })) 37 + 32 38 // Import routes AFTER mocking 33 39 import { voteRoutes } from '../../../src/routes/votes.js' 34 40 ··· 222 228 }) 223 229 224 230 it('creates a vote on a topic and returns 201', async () => { 225 - // 0. Onboarding gate: no mandatory fields 226 - selectChain.where.mockResolvedValueOnce([]) 227 231 // 1. Subject existence check -> topic found 228 232 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 229 233 // 2. Insert returning ··· 265 269 }) 266 270 267 271 it('creates a vote on a reply and returns 201', async () => { 268 - // 0. Onboarding gate: no mandatory fields 269 - selectChain.where.mockResolvedValueOnce([]) 270 272 // 1. Subject existence check -> reply found 271 273 selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]) 272 274 // 2. Insert returning ··· 296 298 isTrackedFn.mockResolvedValue(false) 297 299 trackRepoFn.mockResolvedValue(undefined) 298 300 299 - // 0. Onboarding gate: no mandatory fields 300 - selectChain.where.mockResolvedValueOnce([]) 301 301 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 302 302 insertChain.returning.mockResolvedValueOnce([sampleVoteRow()]) 303 303 ··· 360 360 }) 361 361 362 362 it('returns 400 for invalid direction', async () => { 363 - // 0. Onboarding gate: no mandatory fields 364 - selectChain.where.mockResolvedValueOnce([]) 365 - 366 363 const response = await app.inject({ 367 364 method: 'POST', 368 365 url: '/api/votes', ··· 389 386 }) 390 387 391 388 it('returns 404 when subject does not exist', async () => { 392 - // 0. Onboarding gate: no mandatory fields 393 - selectChain.where.mockResolvedValueOnce([]) 394 389 // Subject not found 395 390 selectChain.where.mockResolvedValueOnce([]) 396 391 ··· 409 404 }) 410 405 411 406 it('returns 404 when subject URI has unknown collection', async () => { 412 - // 0. Onboarding gate: no mandatory fields 413 - selectChain.where.mockResolvedValueOnce([]) 414 - 415 407 const response = await app.inject({ 416 408 method: 'POST', 417 409 url: '/api/votes', ··· 427 419 }) 428 420 429 421 it('returns 409 when duplicate vote (unique constraint)', async () => { 430 - // 0. Onboarding gate: no mandatory fields 431 - selectChain.where.mockResolvedValueOnce([]) 432 422 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 433 423 // onConflictDoNothing -> returning() returns empty array 434 424 insertChain.returning.mockResolvedValueOnce([]) ··· 448 438 }) 449 439 450 440 it('returns 502 when PDS write fails', async () => { 451 - // 0. Onboarding gate: no mandatory fields 452 - selectChain.where.mockResolvedValueOnce([]) 453 441 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 454 442 createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) 455 443