Barazo AppView backend barazo.forum

feat(votes): implement full vote system from lexicon to API (#81)

* feat(votes): implement full vote system from lexicon to API

Wire up the forum.barazo.interaction.vote lexicon that was defined in
barazo-lexicons but had no backend implementation. Votes created via
the firehose are now indexed, and authenticated users can cast/remove
votes through the API.

- Database: votes table + voteCount on topics and replies
- Firehose: VoteIndexer (create/delete) + pipeline integration
- API: POST /api/votes, DELETE /api/votes/:uri, GET /api/votes/status
- OAuth: added vote collection to BARAZO_BASE_SCOPES
- Tests: 42 new/updated tests, full suite passes (2031/2031)

Note: existing users must re-authenticate to acquire the new scope.

* fix(tests): add VoteIndexer to integration test RecordHandler setup

The integration test creates its own RecordHandler with explicit
indexers. The Indexers interface now requires a vote indexer, so the
integration test must provide one. Also cleans the votes table in
beforeEach.

* chore(db): add migration for votes table and voteCount columns

Adds migration 0029_vote_system that creates the votes table with
indexes and unique constraint, plus vote_count columns on topics
and replies. Also registers votes.ts in drizzle.config.ts.

authored by

Guido X Jansen and committed by
GitHub
3ac07a67 7894804e

+1567 -8
+1
drizzle.config.ts
··· 7 7 './src/db/schema/topics.ts', 8 8 './src/db/schema/replies.ts', 9 9 './src/db/schema/reactions.ts', 10 + './src/db/schema/votes.ts', 10 11 './src/db/schema/tracked-repos.ts', 11 12 './src/db/schema/community-settings.ts', 12 13 './src/db/schema/categories.ts',
+23
drizzle/0029_vote_system.sql
··· 1 + CREATE TABLE IF NOT EXISTS "votes" ( 2 + "uri" text PRIMARY KEY NOT NULL, 3 + "rkey" text NOT NULL, 4 + "author_did" text NOT NULL, 5 + "subject_uri" text NOT NULL, 6 + "subject_cid" text NOT NULL, 7 + "direction" text NOT NULL, 8 + "community_did" text NOT NULL, 9 + "cid" text NOT NULL, 10 + "created_at" timestamp with time zone NOT NULL, 11 + "indexed_at" timestamp with time zone DEFAULT now() NOT NULL, 12 + CONSTRAINT "votes_author_subject_uniq" UNIQUE("author_did","subject_uri") 13 + ); 14 + --> statement-breakpoint 15 + CREATE INDEX IF NOT EXISTS "votes_author_did_idx" ON "votes" USING btree ("author_did"); 16 + --> statement-breakpoint 17 + CREATE INDEX IF NOT EXISTS "votes_subject_uri_idx" ON "votes" USING btree ("subject_uri"); 18 + --> statement-breakpoint 19 + CREATE INDEX IF NOT EXISTS "votes_community_did_idx" ON "votes" USING btree ("community_did"); 20 + --> statement-breakpoint 21 + ALTER TABLE "topics" ADD COLUMN "vote_count" integer DEFAULT 0 NOT NULL; 22 + --> statement-breakpoint 23 + ALTER TABLE "replies" ADD COLUMN "vote_count" integer DEFAULT 0 NOT NULL;
+7
drizzle/meta/_journal.json
··· 204 204 "when": 1771624800000, 205 205 "tag": "0028_reply_mod_deleted", 206 206 "breakpoints": true 207 + }, 208 + { 209 + "idx": 29, 210 + "version": "7", 211 + "when": 1771790400000, 212 + "tag": "0029_vote_system", 213 + "breakpoints": true 207 214 } 208 215 ] 209 216 }
+2
src/app.ts
··· 27 27 import { categoryRoutes } from './routes/categories.js' 28 28 import { adminSettingsRoutes } from './routes/admin-settings.js' 29 29 import { reactionRoutes } from './routes/reactions.js' 30 + import { voteRoutes } from './routes/votes.js' 30 31 import { moderationRoutes } from './routes/moderation.js' 31 32 import { moderationQueueRoutes } from './routes/moderation-queue.js' 32 33 import { searchRoutes } from './routes/search.js' ··· 288 289 await app.register(categoryRoutes()) 289 290 await app.register(adminSettingsRoutes()) 290 291 await app.register(reactionRoutes()) 292 + await app.register(voteRoutes()) 291 293 await app.register(moderationRoutes()) 292 294 await app.register(moderationQueueRoutes()) 293 295 await app.register(searchRoutes())
+1 -1
src/auth/scopes.ts
··· 11 11 12 12 /** Base scopes for core Barazo forum operations (read/write own forum records). */ 13 13 export const BARAZO_BASE_SCOPES = 14 - 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction' 14 + 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction repo:forum.barazo.interaction.vote' 15 15 16 16 /** Additional scopes needed for cross-posting to Bluesky and Frontpage. */ 17 17 export const CROSSPOST_ADDITIONAL_SCOPES =
+1
src/db/schema/index.ts
··· 3 3 export { topics } from './topics.js' 4 4 export { replies } from './replies.js' 5 5 export { reactions } from './reactions.js' 6 + export { votes } from './votes.js' 6 7 export { trackedRepos } from './tracked-repos.js' 7 8 export { communitySettings } from './community-settings.js' 8 9 export { categories } from './categories.js'
+1
src/db/schema/replies.ts
··· 16 16 cid: text('cid').notNull(), 17 17 labels: jsonb('labels').$type<{ values: { val: string }[] }>(), 18 18 reactionCount: integer('reaction_count').notNull().default(0), 19 + voteCount: integer('vote_count').notNull().default(0), 19 20 createdAt: timestamp('created_at', { withTimezone: true }).notNull(), 20 21 indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), 21 22 isAuthorDeleted: boolean('is_author_deleted').notNull().default(false),
+1
src/db/schema/topics.ts
··· 16 16 labels: jsonb('labels').$type<{ values: { val: string }[] }>(), 17 17 replyCount: integer('reply_count').notNull().default(0), 18 18 reactionCount: integer('reaction_count').notNull().default(0), 19 + voteCount: integer('vote_count').notNull().default(0), 19 20 lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).notNull().defaultNow(), 20 21 createdAt: timestamp('created_at', { withTimezone: true }).notNull(), 21 22 indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(),
+24
src/db/schema/votes.ts
··· 1 + import { pgTable, text, timestamp, index, unique } from 'drizzle-orm/pg-core' 2 + 3 + export const votes = pgTable( 4 + 'votes', 5 + { 6 + uri: text('uri').primaryKey(), 7 + rkey: text('rkey').notNull(), 8 + authorDid: text('author_did').notNull(), 9 + subjectUri: text('subject_uri').notNull(), 10 + subjectCid: text('subject_cid').notNull(), 11 + direction: text('direction').notNull(), 12 + communityDid: text('community_did').notNull(), 13 + cid: text('cid').notNull(), 14 + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), 15 + indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), 16 + }, 17 + (table) => [ 18 + index('votes_author_did_idx').on(table.authorDid), 19 + index('votes_subject_uri_idx').on(table.subjectUri), 20 + index('votes_community_did_idx').on(table.communityDid), 21 + // One vote per user per subject (regardless of direction) 22 + unique('votes_author_subject_uniq').on(table.authorDid, table.subjectUri), 23 + ] 24 + )
+30 -1
src/firehose/handlers/record.ts
··· 2 2 import { users } from '../../db/schema/users.js' 3 3 import { replies } from '../../db/schema/replies.js' 4 4 import { reactions } from '../../db/schema/reactions.js' 5 + import { votes } from '../../db/schema/votes.js' 5 6 import type { Database } from '../../db/index.js' 6 7 import type { Logger } from '../../lib/logger.js' 7 8 import type { RecordEvent } from '../types.js' ··· 10 11 import type { TopicIndexer } from '../indexers/topic.js' 11 12 import type { ReplyIndexer } from '../indexers/reply.js' 12 13 import type { ReactionIndexer } from '../indexers/reaction.js' 14 + import type { VoteIndexer } from '../indexers/vote.js' 13 15 import type { AccountAgeService, TrustStatus } from '../../services/account-age.js' 14 16 15 17 interface Indexers { 16 18 topic: TopicIndexer 17 19 reply: ReplyIndexer 18 20 reaction: ReactionIndexer 21 + vote: VoteIndexer 19 22 } 20 23 21 24 export class RecordHandler { ··· 109 112 case 'reaction': 110 113 await this.indexers.reaction.handleCreate(params) 111 114 break 115 + case 'vote': 116 + await this.indexers.vote.handleCreate(params) 117 + break 112 118 } 113 119 } 114 120 ··· 131 137 case 'reply': 132 138 await this.indexers.reply.handleUpdate(params) 133 139 break 134 - // Reactions don't have update 140 + // Reactions and votes don't have update 135 141 } 136 142 } 137 143 ··· 195 201 rkey: params.rkey, 196 202 did: params.did, 197 203 subjectUri, 204 + }) 205 + break 206 + } 207 + case 'vote': { 208 + // Same pattern: look up subjectUri before the row is deleted. 209 + const voteRows = await this.db 210 + .select({ subjectUri: votes.subjectUri }) 211 + .from(votes) 212 + .where(eq(votes.uri, params.uri)) 213 + 214 + const voteSubjectUri = voteRows[0]?.subjectUri ?? '' 215 + if (!voteSubjectUri) { 216 + this.logger.debug( 217 + { uri: params.uri }, 218 + 'Vote not found in DB for delete, count decrement will be skipped' 219 + ) 220 + } 221 + 222 + await this.indexers.vote.handleDelete({ 223 + uri: params.uri, 224 + rkey: params.rkey, 225 + did: params.did, 226 + subjectUri: voteSubjectUri, 198 227 }) 199 228 break 200 229 }
+112
src/firehose/indexers/vote.ts
··· 1 + import { eq, sql } from 'drizzle-orm' 2 + import { votes } from '../../db/schema/votes.js' 3 + import { topics } from '../../db/schema/topics.js' 4 + import { replies } from '../../db/schema/replies.js' 5 + import type { Database } from '../../db/index.js' 6 + import type { Logger } from '../../lib/logger.js' 7 + import { clampCreatedAt } from '../clamp-timestamp.js' 8 + import { getCollectionFromUri } from '../../lib/at-uri.js' 9 + 10 + const TOPIC_COLLECTION = 'forum.barazo.topic.post' 11 + const REPLY_COLLECTION = 'forum.barazo.topic.reply' 12 + 13 + /** Transaction type extracted from Database.transaction() callback parameter */ 14 + type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0] 15 + 16 + interface CreateParams { 17 + uri: string 18 + rkey: string 19 + did: string 20 + cid: string 21 + record: Record<string, unknown> 22 + live: boolean 23 + } 24 + 25 + interface DeleteParams { 26 + uri: string 27 + rkey: string 28 + did: string 29 + subjectUri: string 30 + } 31 + 32 + export class VoteIndexer { 33 + constructor( 34 + private db: Database, 35 + private logger: Logger 36 + ) {} 37 + 38 + async handleCreate(params: CreateParams): Promise<void> { 39 + const { uri, rkey, did, cid, record, live } = params 40 + const subject = record['subject'] as { uri: string; cid: string } 41 + const clientCreatedAt = new Date(record['createdAt'] as string) 42 + const createdAt = live ? clampCreatedAt(clientCreatedAt) : clientCreatedAt 43 + 44 + await this.db.transaction(async (tx) => { 45 + await tx 46 + .insert(votes) 47 + .values({ 48 + uri, 49 + rkey, 50 + authorDid: did, 51 + subjectUri: subject.uri, 52 + subjectCid: subject.cid, 53 + direction: record['direction'] as string, 54 + communityDid: record['community'] as string, 55 + cid, 56 + createdAt, 57 + }) 58 + .onConflictDoNothing() 59 + 60 + await this.incrementVoteCount(tx, subject.uri) 61 + }) 62 + 63 + this.logger.debug({ uri, did }, 'Indexed vote') 64 + } 65 + 66 + async handleDelete(params: DeleteParams): Promise<void> { 67 + const { uri, subjectUri } = params 68 + 69 + await this.db.transaction(async (tx) => { 70 + await tx.delete(votes).where(eq(votes.uri, uri)) 71 + await this.decrementVoteCount(tx, subjectUri) 72 + }) 73 + 74 + this.logger.debug({ uri }, 'Deleted vote') 75 + } 76 + 77 + private async incrementVoteCount(tx: Transaction, subjectUri: string): Promise<void> { 78 + const collection = getCollectionFromUri(subjectUri) 79 + 80 + if (collection === TOPIC_COLLECTION) { 81 + await tx 82 + .update(topics) 83 + .set({ voteCount: sql`${topics.voteCount} + 1` }) 84 + .where(eq(topics.uri, subjectUri)) 85 + } else if (collection === REPLY_COLLECTION) { 86 + await tx 87 + .update(replies) 88 + .set({ voteCount: sql`${replies.voteCount} + 1` }) 89 + .where(eq(replies.uri, subjectUri)) 90 + } 91 + } 92 + 93 + private async decrementVoteCount(tx: Transaction, subjectUri: string): Promise<void> { 94 + const collection = getCollectionFromUri(subjectUri) 95 + 96 + if (collection === TOPIC_COLLECTION) { 97 + await tx 98 + .update(topics) 99 + .set({ 100 + voteCount: sql`GREATEST(${topics.voteCount} - 1, 0)`, 101 + }) 102 + .where(eq(topics.uri, subjectUri)) 103 + } else if (collection === REPLY_COLLECTION) { 104 + await tx 105 + .update(replies) 106 + .set({ 107 + voteCount: sql`GREATEST(${replies.voteCount} - 1, 0)`, 108 + }) 109 + .where(eq(replies.uri, subjectUri)) 110 + } 111 + } 112 + }
+3 -1
src/firehose/service.ts
··· 9 9 import { TopicIndexer } from './indexers/topic.js' 10 10 import { ReplyIndexer } from './indexers/reply.js' 11 11 import { ReactionIndexer } from './indexers/reaction.js' 12 + import { VoteIndexer } from './indexers/vote.js' 12 13 import { RecordHandler } from './handlers/record.js' 13 14 import { IdentityHandler } from './handlers/identity.js' 14 15 import { createAccountAgeService } from '../services/account-age.js' ··· 44 45 const topicIndexer = new TopicIndexer(db, logger) 45 46 const replyIndexer = new ReplyIndexer(db, logger) 46 47 const reactionIndexer = new ReactionIndexer(db, logger) 48 + const voteIndexer = new VoteIndexer(db, logger) 47 49 const accountAgeService = createAccountAgeService(logger) 48 50 49 51 this.recordHandler = new RecordHandler( 50 - { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer }, 52 + { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer, vote: voteIndexer }, 51 53 db, 52 54 logger, 53 55 accountAgeService
+2
src/firehose/types.ts
··· 49 49 'forum.barazo.topic.post', 50 50 'forum.barazo.topic.reply', 51 51 'forum.barazo.interaction.reaction', 52 + 'forum.barazo.interaction.vote', 52 53 ] as const satisfies ReadonlyArray<(typeof LEXICON_IDS)[keyof typeof LEXICON_IDS]> 53 54 54 55 export type SupportedCollection = (typeof SUPPORTED_COLLECTIONS)[number] ··· 63 64 'forum.barazo.topic.post': 'topic', 64 65 'forum.barazo.topic.reply': 'reply', 65 66 'forum.barazo.interaction.reaction': 'reaction', 67 + 'forum.barazo.interaction.vote': 'vote', 66 68 } as const
+7 -1
src/firehose/validation.ts
··· 1 - import { topicPostSchema, topicReplySchema, reactionSchema } from '@barazo-forum/lexicons' 1 + import { 2 + topicPostSchema, 3 + topicReplySchema, 4 + reactionSchema, 5 + voteSchema, 6 + } from '@barazo-forum/lexicons' 2 7 import type { SupportedCollection } from './types.js' 3 8 import { isSupportedCollection } from './types.js' 4 9 ··· 15 20 'forum.barazo.topic.post': topicPostSchema, 16 21 'forum.barazo.topic.reply': topicReplySchema, 17 22 'forum.barazo.interaction.reaction': reactionSchema, 23 + 'forum.barazo.interaction.vote': voteSchema, 18 24 } 19 25 20 26 export function validateRecord(collection: string, record: unknown): ValidationResult {
+412
src/routes/votes.ts
··· 1 + import { eq, and, sql } from 'drizzle-orm' 2 + import type { FastifyPluginCallback } from 'fastify' 3 + import { getCommunityDid } from '../config/env.js' 4 + import { createPdsClient } from '../lib/pds-client.js' 5 + import { 6 + notFound, 7 + forbidden, 8 + badRequest, 9 + conflict, 10 + errorResponseSchema, 11 + sendError, 12 + } from '../lib/api-errors.js' 13 + import { createVoteSchema, voteStatusQuerySchema } from '../validation/votes.js' 14 + import { votes } from '../db/schema/votes.js' 15 + import { topics } from '../db/schema/topics.js' 16 + import { replies } from '../db/schema/replies.js' 17 + import { checkOnboardingComplete } from '../lib/onboarding-gate.js' 18 + import { extractRkey, getCollectionFromUri } from '../lib/at-uri.js' 19 + 20 + // --------------------------------------------------------------------------- 21 + // Constants 22 + // --------------------------------------------------------------------------- 23 + 24 + const COLLECTION = 'forum.barazo.interaction.vote' 25 + const TOPIC_COLLECTION = 'forum.barazo.topic.post' 26 + const REPLY_COLLECTION = 'forum.barazo.topic.reply' 27 + 28 + // Upvote-only for now; "down" can be added later without breaking change 29 + const ALLOWED_DIRECTIONS = ['up'] 30 + 31 + // --------------------------------------------------------------------------- 32 + // Vote routes plugin 33 + // --------------------------------------------------------------------------- 34 + 35 + /** 36 + * Vote routes for the Barazo forum. 37 + * 38 + * - POST /api/votes -- Cast a vote 39 + * - DELETE /api/votes/:uri -- Remove a vote 40 + * - GET /api/votes/status -- Check if user voted on a subject 41 + */ 42 + export function voteRoutes(): FastifyPluginCallback { 43 + return (app, _opts, done) => { 44 + const { db, env, authMiddleware, firehose } = app 45 + const pdsClient = createPdsClient(app.oauthClient, app.log) 46 + 47 + // ------------------------------------------------------------------- 48 + // POST /api/votes (auth required) 49 + // ------------------------------------------------------------------- 50 + 51 + app.post( 52 + '/api/votes', 53 + { 54 + preHandler: [authMiddleware.requireAuth], 55 + schema: { 56 + tags: ['Votes'], 57 + summary: 'Cast a vote on a topic or reply', 58 + security: [{ bearerAuth: [] }], 59 + body: { 60 + type: 'object', 61 + required: ['subjectUri', 'subjectCid', 'direction'], 62 + properties: { 63 + subjectUri: { type: 'string', minLength: 1 }, 64 + subjectCid: { type: 'string', minLength: 1 }, 65 + direction: { type: 'string', minLength: 1 }, 66 + }, 67 + }, 68 + response: { 69 + 201: { 70 + type: 'object', 71 + properties: { 72 + uri: { type: 'string' }, 73 + cid: { type: 'string' }, 74 + rkey: { type: 'string' }, 75 + direction: { type: 'string' }, 76 + subjectUri: { type: 'string' }, 77 + createdAt: { type: 'string', format: 'date-time' }, 78 + }, 79 + }, 80 + 400: errorResponseSchema, 81 + 401: errorResponseSchema, 82 + 403: errorResponseSchema, 83 + 404: errorResponseSchema, 84 + 409: errorResponseSchema, 85 + 500: errorResponseSchema, 86 + 502: errorResponseSchema, 87 + }, 88 + }, 89 + }, 90 + async (request, reply) => { 91 + const user = request.user 92 + if (!user) { 93 + return reply.status(401).send({ error: 'Authentication required' }) 94 + } 95 + 96 + const parsed = createVoteSchema.safeParse(request.body) 97 + if (!parsed.success) { 98 + throw badRequest('Invalid vote data') 99 + } 100 + 101 + const { subjectUri, subjectCid, direction } = parsed.data 102 + const communityDid = getCommunityDid(env) 103 + 104 + // Validate direction 105 + if (!ALLOWED_DIRECTIONS.includes(direction)) { 106 + throw badRequest( 107 + `Vote direction "${direction}" is not allowed. Allowed: ${ALLOWED_DIRECTIONS.join(', ')}` 108 + ) 109 + } 110 + 111 + // Onboarding gate 112 + const onboarding = await checkOnboardingComplete(db, user.did, communityDid) 113 + if (!onboarding.complete) { 114 + return reply.status(403).send({ 115 + error: 'Onboarding required', 116 + fields: onboarding.missingFields, 117 + }) 118 + } 119 + 120 + // Verify subject exists and belongs to the same community 121 + const collection = getCollectionFromUri(subjectUri) 122 + let subjectExists = false 123 + 124 + if (collection === TOPIC_COLLECTION) { 125 + const topicRows = await db 126 + .select({ uri: topics.uri }) 127 + .from(topics) 128 + .where(and(eq(topics.uri, subjectUri), eq(topics.communityDid, communityDid))) 129 + subjectExists = topicRows.length > 0 130 + } else if (collection === REPLY_COLLECTION) { 131 + const replyRows = await db 132 + .select({ uri: replies.uri }) 133 + .from(replies) 134 + .where(and(eq(replies.uri, subjectUri), eq(replies.communityDid, communityDid))) 135 + subjectExists = replyRows.length > 0 136 + } 137 + 138 + if (!subjectExists) { 139 + throw notFound('Subject not found') 140 + } 141 + 142 + const now = new Date().toISOString() 143 + 144 + // Build AT Protocol record 145 + const record: Record<string, unknown> = { 146 + subject: { uri: subjectUri, cid: subjectCid }, 147 + direction, 148 + community: communityDid, 149 + createdAt: now, 150 + } 151 + 152 + // Write record to user's PDS 153 + let pdsResult: { uri: string; cid: string } 154 + try { 155 + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) 156 + } catch (err: unknown) { 157 + if (err instanceof Error && 'statusCode' in err) throw err 158 + app.log.error({ err, did: user.did }, 'PDS write failed for vote creation') 159 + return sendError(reply, 502, 'Failed to write to remote PDS') 160 + } 161 + 162 + const rkey = extractRkey(pdsResult.uri) 163 + 164 + try { 165 + // Track repo if this is user's first interaction 166 + const repoManager = firehose.getRepoManager() 167 + const alreadyTracked = await repoManager.isTracked(user.did) 168 + if (!alreadyTracked) { 169 + await repoManager.trackRepo(user.did) 170 + } 171 + 172 + // Optimistically insert into local DB + increment count in a transaction 173 + const insertResult = await db.transaction(async (tx) => { 174 + const inserted = await tx 175 + .insert(votes) 176 + .values({ 177 + uri: pdsResult.uri, 178 + rkey, 179 + authorDid: user.did, 180 + subjectUri, 181 + subjectCid, 182 + direction, 183 + communityDid, 184 + cid: pdsResult.cid, 185 + createdAt: new Date(now), 186 + indexedAt: new Date(), 187 + }) 188 + .onConflictDoNothing() 189 + .returning() 190 + 191 + // If no rows were inserted, the unique constraint was hit (duplicate vote) 192 + if (inserted.length === 0) { 193 + return inserted 194 + } 195 + 196 + // Increment vote count on the subject 197 + if (collection === TOPIC_COLLECTION) { 198 + await tx 199 + .update(topics) 200 + .set({ voteCount: sql`${topics.voteCount} + 1` }) 201 + .where(eq(topics.uri, subjectUri)) 202 + } else if (collection === REPLY_COLLECTION) { 203 + await tx 204 + .update(replies) 205 + .set({ voteCount: sql`${replies.voteCount} + 1` }) 206 + .where(eq(replies.uri, subjectUri)) 207 + } 208 + 209 + return inserted 210 + }) 211 + 212 + if (insertResult.length === 0) { 213 + throw conflict('Vote already exists') 214 + } 215 + 216 + return await reply.status(201).send({ 217 + uri: pdsResult.uri, 218 + cid: pdsResult.cid, 219 + rkey, 220 + direction, 221 + subjectUri, 222 + createdAt: now, 223 + }) 224 + } catch (err: unknown) { 225 + if (err instanceof Error && 'statusCode' in err) throw err 226 + app.log.error({ err, did: user.did }, 'Failed to create vote') 227 + return sendError(reply, 500, 'Failed to save vote locally') 228 + } 229 + } 230 + ) 231 + 232 + // ------------------------------------------------------------------- 233 + // DELETE /api/votes/:uri (auth required, author only) 234 + // ------------------------------------------------------------------- 235 + 236 + app.delete( 237 + '/api/votes/:uri', 238 + { 239 + preHandler: [authMiddleware.requireAuth], 240 + schema: { 241 + tags: ['Votes'], 242 + summary: 'Remove a vote (author only)', 243 + security: [{ bearerAuth: [] }], 244 + params: { 245 + type: 'object', 246 + required: ['uri'], 247 + properties: { 248 + uri: { type: 'string' }, 249 + }, 250 + }, 251 + response: { 252 + 204: { type: 'null' }, 253 + 401: errorResponseSchema, 254 + 403: errorResponseSchema, 255 + 404: errorResponseSchema, 256 + 500: errorResponseSchema, 257 + 502: errorResponseSchema, 258 + }, 259 + }, 260 + }, 261 + async (request, reply) => { 262 + const user = request.user 263 + if (!user) { 264 + return reply.status(401).send({ error: 'Authentication required' }) 265 + } 266 + 267 + const { uri } = request.params as { uri: string } 268 + const decodedUri = decodeURIComponent(uri) 269 + const communityDid = getCommunityDid(env) 270 + 271 + // Fetch existing vote (scoped to this community) 272 + const existing = await db 273 + .select() 274 + .from(votes) 275 + .where(and(eq(votes.uri, decodedUri), eq(votes.communityDid, communityDid))) 276 + 277 + const vote = existing[0] 278 + if (!vote) { 279 + throw notFound('Vote not found') 280 + } 281 + 282 + // Author check 283 + if (vote.authorDid !== user.did) { 284 + throw forbidden('Not authorized to delete this vote') 285 + } 286 + 287 + const rkey = extractRkey(decodedUri) 288 + 289 + // Delete from PDS 290 + try { 291 + await pdsClient.deleteRecord(user.did, COLLECTION, rkey) 292 + } catch (err: unknown) { 293 + if (err instanceof Error && 'statusCode' in err) throw err 294 + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for vote') 295 + return sendError(reply, 502, 'Failed to delete record from remote PDS') 296 + } 297 + 298 + try { 299 + // In transaction: delete from DB + decrement count on subject 300 + await db.transaction(async (tx) => { 301 + await tx 302 + .delete(votes) 303 + .where(and(eq(votes.uri, decodedUri), eq(votes.communityDid, communityDid))) 304 + 305 + const subjectCollection = getCollectionFromUri(vote.subjectUri) 306 + 307 + if (subjectCollection === TOPIC_COLLECTION) { 308 + await tx 309 + .update(topics) 310 + .set({ 311 + voteCount: sql`GREATEST(${topics.voteCount} - 1, 0)`, 312 + }) 313 + .where(eq(topics.uri, vote.subjectUri)) 314 + } else if (subjectCollection === REPLY_COLLECTION) { 315 + await tx 316 + .update(replies) 317 + .set({ 318 + voteCount: sql`GREATEST(${replies.voteCount} - 1, 0)`, 319 + }) 320 + .where(eq(replies.uri, vote.subjectUri)) 321 + } 322 + }) 323 + 324 + return await reply.status(204).send() 325 + } catch (err: unknown) { 326 + if (err instanceof Error && 'statusCode' in err) throw err 327 + app.log.error({ err, uri: decodedUri }, 'Failed to delete vote') 328 + return sendError(reply, 500, 'Failed to delete vote locally') 329 + } 330 + } 331 + ) 332 + 333 + // ------------------------------------------------------------------- 334 + // GET /api/votes/status (public, optionalAuth) 335 + // ------------------------------------------------------------------- 336 + 337 + app.get( 338 + '/api/votes/status', 339 + { 340 + preHandler: [authMiddleware.optionalAuth], 341 + schema: { 342 + tags: ['Votes'], 343 + summary: 'Check if a user has voted on a subject', 344 + querystring: { 345 + type: 'object', 346 + required: ['subjectUri', 'did'], 347 + properties: { 348 + subjectUri: { type: 'string' }, 349 + did: { type: 'string' }, 350 + }, 351 + }, 352 + response: { 353 + 200: { 354 + type: 'object', 355 + properties: { 356 + voted: { type: 'boolean' }, 357 + vote: { 358 + type: ['object', 'null'], 359 + properties: { 360 + uri: { type: 'string' }, 361 + direction: { type: 'string' }, 362 + createdAt: { type: 'string', format: 'date-time' }, 363 + }, 364 + }, 365 + }, 366 + }, 367 + 400: errorResponseSchema, 368 + }, 369 + }, 370 + }, 371 + async (request, reply) => { 372 + const parsed = voteStatusQuerySchema.safeParse(request.query) 373 + if (!parsed.success) { 374 + throw badRequest('Invalid query parameters') 375 + } 376 + 377 + const { subjectUri, did } = parsed.data 378 + const communityDid = getCommunityDid(env) 379 + 380 + const rows = await db 381 + .select({ 382 + uri: votes.uri, 383 + direction: votes.direction, 384 + createdAt: votes.createdAt, 385 + }) 386 + .from(votes) 387 + .where( 388 + and( 389 + eq(votes.authorDid, did), 390 + eq(votes.subjectUri, subjectUri), 391 + eq(votes.communityDid, communityDid) 392 + ) 393 + ) 394 + 395 + const vote = rows[0] 396 + 397 + return reply.status(200).send({ 398 + voted: !!vote, 399 + vote: vote 400 + ? { 401 + uri: vote.uri, 402 + direction: vote.direction, 403 + createdAt: vote.createdAt.toISOString(), 404 + } 405 + : null, 406 + }) 407 + } 408 + ) 409 + 410 + done() 411 + } 412 + }
+26
src/validation/votes.ts
··· 1 + import { z } from 'zod/v4' 2 + 3 + // --------------------------------------------------------------------------- 4 + // Request schemas 5 + // --------------------------------------------------------------------------- 6 + 7 + /** Schema for casting a vote on a topic or reply. */ 8 + export const createVoteSchema = z.object({ 9 + subjectUri: z.string().min(1, 'Subject URI is required'), 10 + subjectCid: z.string().min(1, 'Subject CID is required'), 11 + direction: z.string().min(1, 'Direction is required'), 12 + }) 13 + 14 + export type CreateVoteInput = z.infer<typeof createVoteSchema> 15 + 16 + // --------------------------------------------------------------------------- 17 + // Query schemas 18 + // --------------------------------------------------------------------------- 19 + 20 + /** Schema for checking vote status. */ 21 + export const voteStatusQuerySchema = z.object({ 22 + subjectUri: z.string().min(1, 'Subject URI is required'), 23 + did: z.string().min(1, 'DID is required'), 24 + }) 25 + 26 + export type VoteStatusQueryInput = z.infer<typeof voteStatusQuerySchema>
+5 -1
tests/integration/firehose/record-processing.test.ts
··· 5 5 import { topics } from '../../../src/db/schema/topics.js' 6 6 import { replies } from '../../../src/db/schema/replies.js' 7 7 import { reactions } from '../../../src/db/schema/reactions.js' 8 + import { votes } from '../../../src/db/schema/votes.js' 8 9 import { users } from '../../../src/db/schema/users.js' 9 10 import { TopicIndexer } from '../../../src/firehose/indexers/topic.js' 10 11 import { ReplyIndexer } from '../../../src/firehose/indexers/reply.js' 11 12 import { ReactionIndexer } from '../../../src/firehose/indexers/reaction.js' 13 + import { VoteIndexer } from '../../../src/firehose/indexers/vote.js' 12 14 import { RecordHandler } from '../../../src/firehose/handlers/record.js' 13 15 import type { RecordEvent } from '../../../src/firehose/types.js' 14 16 import type { AccountAgeService } from '../../../src/services/account-age.js' ··· 55 57 const topicIndexer = new TopicIndexer(db, logger as never) 56 58 const replyIndexer = new ReplyIndexer(db, logger as never) 57 59 const reactionIndexer = new ReactionIndexer(db, logger as never) 60 + const voteIndexer = new VoteIndexer(db, logger as never) 58 61 59 62 handler = new RecordHandler( 60 - { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer }, 63 + { topic: topicIndexer, reply: replyIndexer, reaction: reactionIndexer, vote: voteIndexer }, 61 64 db, 62 65 logger as never, 63 66 createStubAccountAgeService() ··· 70 73 71 74 beforeEach(async () => { 72 75 // Clean tables in correct FK-safe order 76 + await db.delete(votes) 73 77 await db.delete(reactions) 74 78 await db.delete(replies) 75 79 await db.delete(topics)
+1 -1
tests/unit/auth/oauth-client.test.ts
··· 153 153 154 154 expect(metadata.client_name).toBe('Barazo Forum') 155 155 expect(metadata.scope).toBe( 156 - 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction' 156 + 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction repo:forum.barazo.interaction.vote' 157 157 ) 158 158 expect(metadata.grant_types).toEqual(['authorization_code', 'refresh_token']) 159 159 expect(metadata.response_types).toEqual(['code'])
+135
tests/unit/firehose/indexers/vote.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest' 2 + import { VoteIndexer } from '../../../../src/firehose/indexers/vote.js' 3 + 4 + function createMockDb() { 5 + const mockTx = { 6 + insert: vi.fn().mockReturnValue({ 7 + values: vi.fn().mockReturnValue({ 8 + onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }), 9 + }), 10 + }), 11 + update: vi.fn().mockReturnValue({ 12 + set: vi.fn().mockReturnValue({ 13 + where: vi.fn().mockResolvedValue(undefined), 14 + }), 15 + }), 16 + delete: vi.fn().mockReturnValue({ 17 + where: vi.fn().mockResolvedValue([{ uri: 'deleted' }]), 18 + }), 19 + } 20 + 21 + return { 22 + insert: vi.fn().mockReturnValue({ 23 + values: vi.fn().mockReturnValue({ 24 + onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }), 25 + }), 26 + }), 27 + update: vi.fn().mockReturnValue({ 28 + set: vi.fn().mockReturnValue({ 29 + where: vi.fn().mockResolvedValue(undefined), 30 + }), 31 + }), 32 + delete: vi.fn().mockReturnValue({ 33 + where: vi.fn().mockResolvedValue([{ uri: 'deleted' }]), 34 + }), 35 + transaction: vi 36 + .fn() 37 + .mockImplementation(async (fn: (tx: typeof mockTx) => Promise<void>) => fn(mockTx)), 38 + _tx: mockTx, 39 + } 40 + } 41 + 42 + function createMockLogger() { 43 + return { 44 + info: vi.fn(), 45 + error: vi.fn(), 46 + warn: vi.fn(), 47 + debug: vi.fn(), 48 + } 49 + } 50 + 51 + describe('VoteIndexer', () => { 52 + const baseParams = { 53 + uri: 'at://did:plc:test/forum.barazo.interaction.vote/vote1', 54 + rkey: 'vote1', 55 + did: 'did:plc:test', 56 + cid: 'bafyvote', 57 + live: true, 58 + } 59 + 60 + describe('handleCreate', () => { 61 + it('upserts a vote and increments count in a transaction', async () => { 62 + const db = createMockDb() 63 + const logger = createMockLogger() 64 + const indexer = new VoteIndexer(db as never, logger as never) 65 + 66 + await indexer.handleCreate({ 67 + ...baseParams, 68 + record: { 69 + subject: { 70 + uri: 'at://did:plc:test/forum.barazo.topic.post/topic1', 71 + cid: 'bafytopic', 72 + }, 73 + direction: 'up', 74 + community: 'did:plc:community', 75 + createdAt: '2026-01-01T00:00:00.000Z', 76 + }, 77 + }) 78 + 79 + expect(db.transaction).toHaveBeenCalledTimes(1) 80 + }) 81 + 82 + it('handles vote on a reply subject', async () => { 83 + const db = createMockDb() 84 + const logger = createMockLogger() 85 + const indexer = new VoteIndexer(db as never, logger as never) 86 + 87 + await indexer.handleCreate({ 88 + ...baseParams, 89 + record: { 90 + subject: { 91 + uri: 'at://did:plc:test/forum.barazo.topic.reply/reply1', 92 + cid: 'bafyreply', 93 + }, 94 + direction: 'up', 95 + community: 'did:plc:community', 96 + createdAt: '2026-01-01T00:00:00.000Z', 97 + }, 98 + }) 99 + 100 + expect(db.transaction).toHaveBeenCalledTimes(1) 101 + }) 102 + }) 103 + 104 + describe('handleDelete', () => { 105 + it('deletes a vote and decrements count in a transaction', async () => { 106 + const db = createMockDb() 107 + const logger = createMockLogger() 108 + const indexer = new VoteIndexer(db as never, logger as never) 109 + 110 + await indexer.handleDelete({ 111 + uri: baseParams.uri, 112 + rkey: baseParams.rkey, 113 + did: baseParams.did, 114 + subjectUri: 'at://did:plc:test/forum.barazo.topic.post/topic1', 115 + }) 116 + 117 + expect(db.transaction).toHaveBeenCalledTimes(1) 118 + }) 119 + 120 + it('handles delete for a reply subject', async () => { 121 + const db = createMockDb() 122 + const logger = createMockLogger() 123 + const indexer = new VoteIndexer(db as never, logger as never) 124 + 125 + await indexer.handleDelete({ 126 + uri: baseParams.uri, 127 + rkey: baseParams.rkey, 128 + did: baseParams.did, 129 + subjectUri: 'at://did:plc:test/forum.barazo.topic.reply/reply1', 130 + }) 131 + 132 + expect(db.transaction).toHaveBeenCalledTimes(1) 133 + }) 134 + }) 135 + })
+10 -2
tests/unit/firehose/types.test.ts
··· 15 15 expect(SUPPORTED_COLLECTIONS).toContain('forum.barazo.interaction.reaction') 16 16 }) 17 17 18 - it('has exactly 3 supported collections', () => { 19 - expect(SUPPORTED_COLLECTIONS).toHaveLength(3) 18 + it('contains vote collection', () => { 19 + expect(SUPPORTED_COLLECTIONS).toContain('forum.barazo.interaction.vote') 20 + }) 21 + 22 + it('has exactly 4 supported collections', () => { 23 + expect(SUPPORTED_COLLECTIONS).toHaveLength(4) 20 24 }) 21 25 }) 22 26 ··· 31 35 32 36 it("maps reaction to 'reaction'", () => { 33 37 expect(COLLECTION_MAP['forum.barazo.interaction.reaction']).toBe('reaction') 38 + }) 39 + 40 + it("maps vote to 'vote'", () => { 41 + expect(COLLECTION_MAP['forum.barazo.interaction.vote']).toBe('vote') 34 42 }) 35 43 36 44 it('returns undefined for unsupported collection', () => {
+32
tests/unit/firehose/validation.test.ts
··· 74 74 }) 75 75 }) 76 76 77 + describe('vote validation', () => { 78 + const validVote = { 79 + subject: { uri: 'at://did:plc:abc/forum.barazo.topic.post/123', cid: 'bafyabc' }, 80 + direction: 'up', 81 + community: 'did:plc:abc123', 82 + createdAt: '2026-01-01T00:00:00.000Z', 83 + } 84 + 85 + it('accepts a valid vote', () => { 86 + const result = validateRecord('forum.barazo.interaction.vote', validVote) 87 + expect(result.success).toBe(true) 88 + }) 89 + 90 + it('rejects a vote with missing direction', () => { 91 + const { direction: _, ...invalid } = validVote 92 + const result = validateRecord('forum.barazo.interaction.vote', invalid) 93 + expect(result.success).toBe(false) 94 + }) 95 + 96 + it('rejects a vote with missing subject', () => { 97 + const { subject: _, ...invalid } = validVote 98 + const result = validateRecord('forum.barazo.interaction.vote', invalid) 99 + expect(result.success).toBe(false) 100 + }) 101 + 102 + it('rejects a vote with missing community', () => { 103 + const { community: _, ...invalid } = validVote 104 + const result = validateRecord('forum.barazo.interaction.vote', invalid) 105 + expect(result.success).toBe(false) 106 + }) 107 + }) 108 + 77 109 describe('unknown collection', () => { 78 110 it('rejects an unknown collection', () => { 79 111 const result = validateRecord('com.example.unknown', { foo: 'bar' })
+731
tests/unit/routes/votes.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' 2 + import Fastify from 'fastify' 3 + import type { FastifyInstance } from 'fastify' 4 + import type { Env } from '../../../src/config/env.js' 5 + import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' 6 + import type { SessionService } from '../../../src/auth/session.js' 7 + import type { SetupService } from '../../../src/setup/service.js' 8 + import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' 9 + 10 + // --------------------------------------------------------------------------- 11 + // Mock PDS client module (must be before importing routes) 12 + // --------------------------------------------------------------------------- 13 + 14 + const createRecordFn = 15 + vi.fn< 16 + ( 17 + did: string, 18 + collection: string, 19 + record: Record<string, unknown> 20 + ) => Promise<{ uri: string; cid: string }> 21 + >() 22 + const deleteRecordFn = vi.fn<(did: string, collection: string, rkey: string) => Promise<void>>() 23 + 24 + vi.mock('../../../src/lib/pds-client.js', () => ({ 25 + createPdsClient: () => ({ 26 + createRecord: createRecordFn, 27 + deleteRecord: deleteRecordFn, 28 + updateRecord: vi.fn(), 29 + }), 30 + })) 31 + 32 + // Import routes AFTER mocking 33 + import { voteRoutes } from '../../../src/routes/votes.js' 34 + 35 + // --------------------------------------------------------------------------- 36 + // Mock env 37 + // --------------------------------------------------------------------------- 38 + 39 + const mockEnv = { 40 + COMMUNITY_DID: 'did:plc:community123', 41 + RATE_LIMIT_WRITE: 10, 42 + RATE_LIMIT_READ_ANON: 100, 43 + RATE_LIMIT_READ_AUTH: 300, 44 + } as Env 45 + 46 + // --------------------------------------------------------------------------- 47 + // Test constants 48 + // --------------------------------------------------------------------------- 49 + 50 + const TEST_DID = 'did:plc:testuser123' 51 + const TEST_HANDLE = 'alice.bsky.social' 52 + const TEST_SID = 'a'.repeat(64) 53 + const OTHER_DID = 'did:plc:otheruser456' 54 + const COMMUNITY_DID = 'did:plc:community123' 55 + 56 + const TEST_TOPIC_URI = `at://${OTHER_DID}/forum.barazo.topic.post/topic123` 57 + const TEST_TOPIC_CID = 'bafyreitopic123' 58 + const TEST_REPLY_URI = `at://${OTHER_DID}/forum.barazo.topic.reply/reply123` 59 + const TEST_REPLY_CID = 'bafyreireply123' 60 + 61 + const TEST_VOTE_URI = `at://${TEST_DID}/forum.barazo.interaction.vote/vote123` 62 + const TEST_VOTE_CID = 'bafyreivote123' 63 + const TEST_NOW = '2026-02-13T12:00:00.000Z' 64 + 65 + // --------------------------------------------------------------------------- 66 + // Mock user builders 67 + // --------------------------------------------------------------------------- 68 + 69 + function testUser(overrides?: Partial<RequestUser>): RequestUser { 70 + return { 71 + did: TEST_DID, 72 + handle: TEST_HANDLE, 73 + sid: TEST_SID, 74 + ...overrides, 75 + } 76 + } 77 + 78 + // --------------------------------------------------------------------------- 79 + // Mock firehose repo manager 80 + // --------------------------------------------------------------------------- 81 + 82 + const isTrackedFn = vi.fn<(did: string) => Promise<boolean>>() 83 + const trackRepoFn = vi.fn<(did: string) => Promise<void>>() 84 + 85 + const mockRepoManager = { 86 + isTracked: isTrackedFn, 87 + trackRepo: trackRepoFn, 88 + untrackRepo: vi.fn(), 89 + restoreTrackedRepos: vi.fn(), 90 + } 91 + 92 + const mockFirehose = { 93 + getRepoManager: () => mockRepoManager, 94 + start: vi.fn(), 95 + stop: vi.fn(), 96 + getStatus: vi.fn().mockReturnValue({ connected: true, lastEventId: null }), 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // Chainable mock DB 101 + // --------------------------------------------------------------------------- 102 + 103 + const mockDb = createMockDb() 104 + 105 + let insertChain: DbChain 106 + let selectChain: DbChain 107 + let updateChain: DbChain 108 + let deleteChain: DbChain 109 + 110 + function resetAllDbMocks(): void { 111 + insertChain = createChainableProxy() 112 + selectChain = createChainableProxy([]) 113 + updateChain = createChainableProxy([]) 114 + deleteChain = createChainableProxy() 115 + mockDb.insert.mockReturnValue(insertChain) 116 + mockDb.select.mockReturnValue(selectChain) 117 + mockDb.update.mockReturnValue(updateChain) 118 + mockDb.delete.mockReturnValue(deleteChain) 119 + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction 120 + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise<unknown>) => { 121 + return await fn(mockDb) 122 + }) 123 + } 124 + 125 + // --------------------------------------------------------------------------- 126 + // Auth middleware mocks 127 + // --------------------------------------------------------------------------- 128 + 129 + function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { 130 + return { 131 + requireAuth: async (request, reply) => { 132 + if (!user) { 133 + await reply.status(401).send({ error: 'Authentication required' }) 134 + return 135 + } 136 + request.user = user 137 + }, 138 + optionalAuth: (request, _reply) => { 139 + if (user) { 140 + request.user = user 141 + } 142 + return Promise.resolve() 143 + }, 144 + } 145 + } 146 + 147 + // --------------------------------------------------------------------------- 148 + // Sample data builders 149 + // --------------------------------------------------------------------------- 150 + 151 + function sampleVoteRow(overrides?: Record<string, unknown>) { 152 + return { 153 + uri: TEST_VOTE_URI, 154 + rkey: 'vote123', 155 + authorDid: TEST_DID, 156 + subjectUri: TEST_TOPIC_URI, 157 + subjectCid: TEST_TOPIC_CID, 158 + direction: 'up', 159 + communityDid: COMMUNITY_DID, 160 + cid: TEST_VOTE_CID, 161 + createdAt: new Date(TEST_NOW), 162 + indexedAt: new Date(TEST_NOW), 163 + ...overrides, 164 + } 165 + } 166 + 167 + // --------------------------------------------------------------------------- 168 + // Helper: build app with mocked deps 169 + // --------------------------------------------------------------------------- 170 + 171 + async function buildTestApp(user?: RequestUser): Promise<FastifyInstance> { 172 + const app = Fastify({ logger: false }) 173 + 174 + app.decorate('db', mockDb as never) 175 + app.decorate('env', mockEnv) 176 + app.decorate('authMiddleware', createMockAuthMiddleware(user)) 177 + app.decorate('firehose', mockFirehose as never) 178 + app.decorate('oauthClient', {} as never) 179 + app.decorate('sessionService', {} as SessionService) 180 + app.decorate('setupService', {} as SetupService) 181 + app.decorate('cache', {} as never) 182 + app.decorateRequest('user', undefined as RequestUser | undefined) 183 + 184 + await app.register(voteRoutes()) 185 + await app.ready() 186 + 187 + return app 188 + } 189 + 190 + // =========================================================================== 191 + // Test suite 192 + // =========================================================================== 193 + 194 + describe('vote routes', () => { 195 + // ========================================================================= 196 + // POST /api/votes 197 + // ========================================================================= 198 + 199 + describe('POST /api/votes', () => { 200 + let app: FastifyInstance 201 + 202 + beforeAll(async () => { 203 + app = await buildTestApp(testUser()) 204 + }) 205 + 206 + afterAll(async () => { 207 + await app.close() 208 + }) 209 + 210 + beforeEach(() => { 211 + vi.clearAllMocks() 212 + resetAllDbMocks() 213 + 214 + // Default mocks for successful create 215 + createRecordFn.mockResolvedValue({ uri: TEST_VOTE_URI, cid: TEST_VOTE_CID }) 216 + isTrackedFn.mockResolvedValue(true) 217 + }) 218 + 219 + it('creates a vote on a topic and returns 201', async () => { 220 + // 0. Onboarding gate: no mandatory fields 221 + selectChain.where.mockResolvedValueOnce([]) 222 + // 1. Subject existence check -> topic found 223 + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 224 + // 2. Insert returning 225 + insertChain.returning.mockResolvedValueOnce([sampleVoteRow()]) 226 + 227 + const response = await app.inject({ 228 + method: 'POST', 229 + url: '/api/votes', 230 + headers: { authorization: 'Bearer test-token' }, 231 + payload: { 232 + subjectUri: TEST_TOPIC_URI, 233 + subjectCid: TEST_TOPIC_CID, 234 + direction: 'up', 235 + }, 236 + }) 237 + 238 + expect(response.statusCode).toBe(201) 239 + const body = response.json<{ 240 + uri: string 241 + cid: string 242 + rkey: string 243 + direction: string 244 + subjectUri: string 245 + }>() 246 + expect(body.uri).toBe(TEST_VOTE_URI) 247 + expect(body.cid).toBe(TEST_VOTE_CID) 248 + expect(body.direction).toBe('up') 249 + expect(body.subjectUri).toBe(TEST_TOPIC_URI) 250 + 251 + // Should have called PDS createRecord 252 + expect(createRecordFn).toHaveBeenCalledOnce() 253 + expect(createRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) 254 + expect(createRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.interaction.vote') 255 + 256 + // Should have inserted into DB 257 + expect(mockDb.insert).toHaveBeenCalledOnce() 258 + // Should have incremented vote count 259 + expect(mockDb.update).toHaveBeenCalledOnce() 260 + }) 261 + 262 + it('creates a vote on a reply and returns 201', async () => { 263 + // 0. Onboarding gate: no mandatory fields 264 + selectChain.where.mockResolvedValueOnce([]) 265 + // 1. Subject existence check -> reply found 266 + selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]) 267 + // 2. Insert returning 268 + const replyVote = sampleVoteRow({ 269 + subjectUri: TEST_REPLY_URI, 270 + subjectCid: TEST_REPLY_CID, 271 + }) 272 + insertChain.returning.mockResolvedValueOnce([replyVote]) 273 + 274 + const response = await app.inject({ 275 + method: 'POST', 276 + url: '/api/votes', 277 + headers: { authorization: 'Bearer test-token' }, 278 + payload: { 279 + subjectUri: TEST_REPLY_URI, 280 + subjectCid: TEST_REPLY_CID, 281 + direction: 'up', 282 + }, 283 + }) 284 + 285 + expect(response.statusCode).toBe(201) 286 + const body = response.json<{ subjectUri: string }>() 287 + expect(body.subjectUri).toBe(TEST_REPLY_URI) 288 + }) 289 + 290 + it("tracks new user's repo on first vote", async () => { 291 + isTrackedFn.mockResolvedValue(false) 292 + trackRepoFn.mockResolvedValue(undefined) 293 + 294 + // 0. Onboarding gate: no mandatory fields 295 + selectChain.where.mockResolvedValueOnce([]) 296 + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 297 + insertChain.returning.mockResolvedValueOnce([sampleVoteRow()]) 298 + 299 + const response = await app.inject({ 300 + method: 'POST', 301 + url: '/api/votes', 302 + headers: { authorization: 'Bearer test-token' }, 303 + payload: { 304 + subjectUri: TEST_TOPIC_URI, 305 + subjectCid: TEST_TOPIC_CID, 306 + direction: 'up', 307 + }, 308 + }) 309 + 310 + expect(response.statusCode).toBe(201) 311 + expect(isTrackedFn).toHaveBeenCalledWith(TEST_DID) 312 + expect(trackRepoFn).toHaveBeenCalledWith(TEST_DID) 313 + }) 314 + 315 + it('returns 400 for missing subjectUri', async () => { 316 + const response = await app.inject({ 317 + method: 'POST', 318 + url: '/api/votes', 319 + headers: { authorization: 'Bearer test-token' }, 320 + payload: { 321 + subjectCid: TEST_TOPIC_CID, 322 + direction: 'up', 323 + }, 324 + }) 325 + 326 + expect(response.statusCode).toBe(400) 327 + }) 328 + 329 + it('returns 400 for missing subjectCid', async () => { 330 + const response = await app.inject({ 331 + method: 'POST', 332 + url: '/api/votes', 333 + headers: { authorization: 'Bearer test-token' }, 334 + payload: { 335 + subjectUri: TEST_TOPIC_URI, 336 + direction: 'up', 337 + }, 338 + }) 339 + 340 + expect(response.statusCode).toBe(400) 341 + }) 342 + 343 + it('returns 400 for missing direction', async () => { 344 + const response = await app.inject({ 345 + method: 'POST', 346 + url: '/api/votes', 347 + headers: { authorization: 'Bearer test-token' }, 348 + payload: { 349 + subjectUri: TEST_TOPIC_URI, 350 + subjectCid: TEST_TOPIC_CID, 351 + }, 352 + }) 353 + 354 + expect(response.statusCode).toBe(400) 355 + }) 356 + 357 + it('returns 400 for invalid direction', async () => { 358 + // 0. Onboarding gate: no mandatory fields 359 + selectChain.where.mockResolvedValueOnce([]) 360 + 361 + const response = await app.inject({ 362 + method: 'POST', 363 + url: '/api/votes', 364 + headers: { authorization: 'Bearer test-token' }, 365 + payload: { 366 + subjectUri: TEST_TOPIC_URI, 367 + subjectCid: TEST_TOPIC_CID, 368 + direction: 'down', 369 + }, 370 + }) 371 + 372 + expect(response.statusCode).toBe(400) 373 + }) 374 + 375 + it('returns 400 for empty body', async () => { 376 + const response = await app.inject({ 377 + method: 'POST', 378 + url: '/api/votes', 379 + headers: { authorization: 'Bearer test-token' }, 380 + payload: {}, 381 + }) 382 + 383 + expect(response.statusCode).toBe(400) 384 + }) 385 + 386 + it('returns 404 when subject does not exist', async () => { 387 + // 0. Onboarding gate: no mandatory fields 388 + selectChain.where.mockResolvedValueOnce([]) 389 + // Subject not found 390 + selectChain.where.mockResolvedValueOnce([]) 391 + 392 + const response = await app.inject({ 393 + method: 'POST', 394 + url: '/api/votes', 395 + headers: { authorization: 'Bearer test-token' }, 396 + payload: { 397 + subjectUri: TEST_TOPIC_URI, 398 + subjectCid: TEST_TOPIC_CID, 399 + direction: 'up', 400 + }, 401 + }) 402 + 403 + expect(response.statusCode).toBe(404) 404 + }) 405 + 406 + it('returns 404 when subject URI has unknown collection', async () => { 407 + // 0. Onboarding gate: no mandatory fields 408 + selectChain.where.mockResolvedValueOnce([]) 409 + 410 + const response = await app.inject({ 411 + method: 'POST', 412 + url: '/api/votes', 413 + headers: { authorization: 'Bearer test-token' }, 414 + payload: { 415 + subjectUri: `at://${OTHER_DID}/some.unknown.collection/xyz123`, 416 + subjectCid: 'bafyreixyz', 417 + direction: 'up', 418 + }, 419 + }) 420 + 421 + expect(response.statusCode).toBe(404) 422 + }) 423 + 424 + it('returns 409 when duplicate vote (unique constraint)', async () => { 425 + // 0. Onboarding gate: no mandatory fields 426 + selectChain.where.mockResolvedValueOnce([]) 427 + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 428 + // onConflictDoNothing -> returning() returns empty array 429 + insertChain.returning.mockResolvedValueOnce([]) 430 + 431 + const response = await app.inject({ 432 + method: 'POST', 433 + url: '/api/votes', 434 + headers: { authorization: 'Bearer test-token' }, 435 + payload: { 436 + subjectUri: TEST_TOPIC_URI, 437 + subjectCid: TEST_TOPIC_CID, 438 + direction: 'up', 439 + }, 440 + }) 441 + 442 + expect(response.statusCode).toBe(409) 443 + }) 444 + 445 + it('returns 502 when PDS write fails', async () => { 446 + // 0. Onboarding gate: no mandatory fields 447 + selectChain.where.mockResolvedValueOnce([]) 448 + selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 449 + createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) 450 + 451 + const response = await app.inject({ 452 + method: 'POST', 453 + url: '/api/votes', 454 + headers: { authorization: 'Bearer test-token' }, 455 + payload: { 456 + subjectUri: TEST_TOPIC_URI, 457 + subjectCid: TEST_TOPIC_CID, 458 + direction: 'up', 459 + }, 460 + }) 461 + 462 + expect(response.statusCode).toBe(502) 463 + }) 464 + }) 465 + 466 + describe('POST /api/votes (unauthenticated)', () => { 467 + let app: FastifyInstance 468 + 469 + beforeAll(async () => { 470 + app = await buildTestApp(undefined) 471 + }) 472 + 473 + afterAll(async () => { 474 + await app.close() 475 + }) 476 + 477 + it('returns 401 without auth', async () => { 478 + const response = await app.inject({ 479 + method: 'POST', 480 + url: '/api/votes', 481 + payload: { 482 + subjectUri: TEST_TOPIC_URI, 483 + subjectCid: TEST_TOPIC_CID, 484 + direction: 'up', 485 + }, 486 + }) 487 + 488 + expect(response.statusCode).toBe(401) 489 + }) 490 + }) 491 + 492 + // ========================================================================= 493 + // DELETE /api/votes/:uri 494 + // ========================================================================= 495 + 496 + describe('DELETE /api/votes/:uri', () => { 497 + let app: FastifyInstance 498 + 499 + beforeAll(async () => { 500 + app = await buildTestApp(testUser()) 501 + }) 502 + 503 + afterAll(async () => { 504 + await app.close() 505 + }) 506 + 507 + beforeEach(() => { 508 + vi.clearAllMocks() 509 + resetAllDbMocks() 510 + deleteRecordFn.mockResolvedValue(undefined) 511 + }) 512 + 513 + it('deletes a vote when user is the author', async () => { 514 + const existingVote = sampleVoteRow() 515 + selectChain.where.mockResolvedValueOnce([existingVote]) 516 + 517 + const encodedUri = encodeURIComponent(TEST_VOTE_URI) 518 + const response = await app.inject({ 519 + method: 'DELETE', 520 + url: `/api/votes/${encodedUri}`, 521 + headers: { authorization: 'Bearer test-token' }, 522 + }) 523 + 524 + expect(response.statusCode).toBe(204) 525 + 526 + // Should have deleted from PDS 527 + expect(deleteRecordFn).toHaveBeenCalledOnce() 528 + expect(deleteRecordFn.mock.calls[0]?.[0]).toBe(TEST_DID) 529 + expect(deleteRecordFn.mock.calls[0]?.[1]).toBe('forum.barazo.interaction.vote') 530 + expect(deleteRecordFn.mock.calls[0]?.[2]).toBe('vote123') 531 + 532 + // Should have used transaction for DB delete + count decrement 533 + expect(mockDb.transaction).toHaveBeenCalledOnce() 534 + expect(mockDb.delete).toHaveBeenCalled() 535 + expect(mockDb.update).toHaveBeenCalled() 536 + }) 537 + 538 + it('decrements vote count on the subject topic', async () => { 539 + const existingVote = sampleVoteRow({ 540 + subjectUri: TEST_TOPIC_URI, 541 + }) 542 + selectChain.where.mockResolvedValueOnce([existingVote]) 543 + 544 + const encodedUri = encodeURIComponent(TEST_VOTE_URI) 545 + const response = await app.inject({ 546 + method: 'DELETE', 547 + url: `/api/votes/${encodedUri}`, 548 + headers: { authorization: 'Bearer test-token' }, 549 + }) 550 + 551 + expect(response.statusCode).toBe(204) 552 + expect(mockDb.update).toHaveBeenCalled() 553 + }) 554 + 555 + it('decrements vote count on the subject reply', async () => { 556 + const existingVote = sampleVoteRow({ 557 + subjectUri: TEST_REPLY_URI, 558 + }) 559 + selectChain.where.mockResolvedValueOnce([existingVote]) 560 + 561 + const encodedUri = encodeURIComponent(TEST_VOTE_URI) 562 + const response = await app.inject({ 563 + method: 'DELETE', 564 + url: `/api/votes/${encodedUri}`, 565 + headers: { authorization: 'Bearer test-token' }, 566 + }) 567 + 568 + expect(response.statusCode).toBe(204) 569 + expect(mockDb.update).toHaveBeenCalled() 570 + }) 571 + 572 + it('returns 403 when user is not the author', async () => { 573 + const existingVote = sampleVoteRow({ authorDid: OTHER_DID }) 574 + selectChain.where.mockResolvedValueOnce([existingVote]) 575 + 576 + const encodedUri = encodeURIComponent(TEST_VOTE_URI) 577 + const response = await app.inject({ 578 + method: 'DELETE', 579 + url: `/api/votes/${encodedUri}`, 580 + headers: { authorization: 'Bearer test-token' }, 581 + }) 582 + 583 + expect(response.statusCode).toBe(403) 584 + }) 585 + 586 + it('returns 404 when vote does not exist', async () => { 587 + selectChain.where.mockResolvedValueOnce([]) 588 + 589 + const encodedUri = encodeURIComponent( 590 + 'at://did:plc:nobody/forum.barazo.interaction.vote/ghost' 591 + ) 592 + const response = await app.inject({ 593 + method: 'DELETE', 594 + url: `/api/votes/${encodedUri}`, 595 + headers: { authorization: 'Bearer test-token' }, 596 + }) 597 + 598 + expect(response.statusCode).toBe(404) 599 + }) 600 + 601 + it('returns 502 when PDS delete fails', async () => { 602 + const existingVote = sampleVoteRow() 603 + selectChain.where.mockResolvedValueOnce([existingVote]) 604 + deleteRecordFn.mockRejectedValueOnce(new Error('PDS delete failed')) 605 + 606 + const encodedUri = encodeURIComponent(TEST_VOTE_URI) 607 + const response = await app.inject({ 608 + method: 'DELETE', 609 + url: `/api/votes/${encodedUri}`, 610 + headers: { authorization: 'Bearer test-token' }, 611 + }) 612 + 613 + expect(response.statusCode).toBe(502) 614 + }) 615 + }) 616 + 617 + describe('DELETE /api/votes/:uri (unauthenticated)', () => { 618 + let app: FastifyInstance 619 + 620 + beforeAll(async () => { 621 + app = await buildTestApp(undefined) 622 + }) 623 + 624 + afterAll(async () => { 625 + await app.close() 626 + }) 627 + 628 + it('returns 401 without auth', async () => { 629 + const encodedUri = encodeURIComponent(TEST_VOTE_URI) 630 + const response = await app.inject({ 631 + method: 'DELETE', 632 + url: `/api/votes/${encodedUri}`, 633 + headers: {}, 634 + }) 635 + 636 + expect(response.statusCode).toBe(401) 637 + }) 638 + }) 639 + 640 + // ========================================================================= 641 + // GET /api/votes/status 642 + // ========================================================================= 643 + 644 + describe('GET /api/votes/status', () => { 645 + let app: FastifyInstance 646 + 647 + beforeAll(async () => { 648 + app = await buildTestApp(testUser()) 649 + }) 650 + 651 + afterAll(async () => { 652 + await app.close() 653 + }) 654 + 655 + beforeEach(() => { 656 + vi.clearAllMocks() 657 + resetAllDbMocks() 658 + }) 659 + 660 + it('returns voted=true when user has voted', async () => { 661 + selectChain.where.mockResolvedValueOnce([ 662 + { 663 + uri: TEST_VOTE_URI, 664 + direction: 'up', 665 + createdAt: new Date(TEST_NOW), 666 + }, 667 + ]) 668 + 669 + const response = await app.inject({ 670 + method: 'GET', 671 + url: `/api/votes/status?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&did=${encodeURIComponent(TEST_DID)}`, 672 + }) 673 + 674 + expect(response.statusCode).toBe(200) 675 + const body = response.json<{ 676 + voted: boolean 677 + vote: { uri: string; direction: string; createdAt: string } | null 678 + }>() 679 + expect(body.voted).toBe(true) 680 + expect(body.vote).not.toBeNull() 681 + expect(body.vote?.uri).toBe(TEST_VOTE_URI) 682 + expect(body.vote?.direction).toBe('up') 683 + expect(body.vote?.createdAt).toBe(TEST_NOW) 684 + }) 685 + 686 + it('returns voted=false when user has not voted', async () => { 687 + selectChain.where.mockResolvedValueOnce([]) 688 + 689 + const response = await app.inject({ 690 + method: 'GET', 691 + url: `/api/votes/status?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&did=${encodeURIComponent(TEST_DID)}`, 692 + }) 693 + 694 + expect(response.statusCode).toBe(200) 695 + const body = response.json<{ voted: boolean; vote: null }>() 696 + expect(body.voted).toBe(false) 697 + expect(body.vote).toBeNull() 698 + }) 699 + 700 + it('returns 400 for missing subjectUri', async () => { 701 + const response = await app.inject({ 702 + method: 'GET', 703 + url: `/api/votes/status?did=${encodeURIComponent(TEST_DID)}`, 704 + }) 705 + 706 + expect(response.statusCode).toBe(400) 707 + }) 708 + 709 + it('returns 400 for missing did', async () => { 710 + const response = await app.inject({ 711 + method: 'GET', 712 + url: `/api/votes/status?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}`, 713 + }) 714 + 715 + expect(response.statusCode).toBe(400) 716 + }) 717 + 718 + it('works without authentication (public endpoint)', async () => { 719 + const noAuthApp = await buildTestApp(undefined) 720 + selectChain.where.mockResolvedValueOnce([]) 721 + 722 + const response = await noAuthApp.inject({ 723 + method: 'GET', 724 + url: `/api/votes/status?subjectUri=${encodeURIComponent(TEST_TOPIC_URI)}&did=${encodeURIComponent(TEST_DID)}`, 725 + }) 726 + 727 + expect(response.statusCode).toBe(200) 728 + await noAuthApp.close() 729 + }) 730 + }) 731 + })