Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions functions/src/api/routes/events/getVotes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mockFirebaseAdminApp } from '../../../testUtils/firestoreMock'
import { mockWhere } from 'firestore-vitest/mocks/firestore'

// Event (project) doc as stored in Firestore, with the public-data URL and the
// vote items + a seeded sessionVotes subcollection.
const seededProject = {
id: 'proj_123',
name: 'Test Event',
owner: 'user_123',
members: ['user_123'],
organizationId: 'org_123',
config: { jsonUrl: 'https://data.example.test/openfeedback.json' },
voteItems: [{ id: 'q1', name: 'Quality' }],
_collections: {
sessionVotes: [
{
id: 'session1',
// uidB has no `plus` — must default to 0, not "(undefined)".
q1: {
uidA: { text: 'Great talk', plus: 2 },
uidB: { text: 'Loved it' },
},
},
],
},
}

// The event's public sessions/speakers JSON (fetched from config.jsonUrl).
const openFeedbackJson = {
sessions: {
session1: {
title: 'Talk 1',
speakers: ['spk1'],
tags: ['frontend'],
trackTitle: 'Track A',
},
},
speakers: { spk1: { name: 'Alice' } },
}

const stubFetch = (json: unknown = openFeedbackJson, ok = true, status = 200) =>
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok, status, json: async () => json }))
)

// Stub the private-subcollection key resolution used by authenticateRequest:
// collectionGroup('private').where(...).limit(1).get(), grandparent = entity.
const mockKeyResolves = (
parentCollection: string,
doc: Record<string, unknown>
) => {
const entityDoc = { exists: true, id: doc.id as string, data: () => doc }
const entityRef = {
id: doc.id as string,
parent: { id: parentCollection },
get: () => entityDoc,
}
const integrationDoc = {
ref: {
id: 'integration',
parent: { parent: entityRef },
set: () => Promise.resolve(),
},
}
mockWhere.mockImplementation(() => ({
limit: () => ({
get: () => ({ empty: false, docs: [integrationDoc] }),
}),
}))
}

const mockProjectKey = (project = seededProject) =>
mockKeyResolves('projects', project)
const mockOrgKey = (org: Record<string, unknown> = { id: 'org_123' }) =>
mockKeyResolves('organizations', org)

const expectedRow = {
sessionId: 'session1',
title: 'Talk 1',
speakers: 'spk1',
speakersName: 'Alice',
tags: 'frontend',
trackTitle: 'Track A',
Quality: 'Great talk (2), Loved it (0)',
}

describe('/events/:projectId/votes', () => {
let fastify: any

beforeEach(async () => {
mockFirebaseAdminApp({ projects: [seededProject] })
stubFetch()
const { createFastifyAPI } = await import('../../api')
fastify = await createFastifyAPI()
})

afterEach(async () => {
if (fastify) {
await fastify.close()
}
vi.unstubAllGlobals()
vi.clearAllMocks()
})

it('returns 401 when no API key is provided', async () => {
const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
})
expect(response.statusCode).toBe(401)
})

it('exports votes for the event matching a project key', async () => {
mockProjectKey()

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
headers: { 'x-api-key': 'ofproj_test-key' },
})

expect(response.statusCode).toBe(200)
const body = JSON.parse(response.body)
expect(body.projectId).toBe('proj_123')
expect(body.sessionsCount).toBe(1)
expect(body.sessions[0]).toEqual(expectedRow)
})

it('rejects a project key for a different event with 404', async () => {
mockProjectKey({ ...seededProject, id: 'proj_other' })

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
headers: { 'x-api-key': 'ofproj_test-key' },
})

expect(response.statusCode).toBe(404)
})

it('exports votes for an event in the organization (org key)', async () => {
mockOrgKey({ id: 'org_123' })

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
headers: { 'x-api-key': 'oforg_test-key' },
})

expect(response.statusCode).toBe(200)
const body = JSON.parse(response.body)
expect(body.sessionsCount).toBe(1)
expect(body.sessions[0]).toEqual(expectedRow)
})

it('rejects an org key for an event in another organization with 404', async () => {
mockOrgKey({ id: 'org_999' })

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
headers: { 'x-api-key': 'oforg_test-key' },
})

expect(response.statusCode).toBe(404)
expect(JSON.parse(response.body).error).toBe('Event not found')
})

it('returns the same 404 for a non-existent event (not enumerable)', async () => {
mockOrgKey({ id: 'org_123' })

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_missing/votes',
headers: { 'x-api-key': 'oforg_test-key' },
})

expect(response.statusCode).toBe(404)
// Identical message to the wrong-org case above: ids stay opaque.
expect(JSON.parse(response.body).error).toBe('Event not found')
})

it('returns 400 when the public data URL responds with an error', async () => {
stubFetch(openFeedbackJson, false, 502)
mockProjectKey()

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
headers: { 'x-api-key': 'ofproj_test-key' },
})

expect(response.statusCode).toBe(400)
})

it('returns 400 when the event has no public data URL', async () => {
const { config: _omit, ...noUrlProject } = seededProject
mockProjectKey(noUrlProject as typeof seededProject)

const response = await fastify.inject({
method: 'GET',
url: '/events/proj_123/votes',
headers: { 'x-api-key': 'ofproj_test-key' },
})

expect(response.statusCode).toBe(400)
})
})
93 changes: 93 additions & 0 deletions functions/src/api/routes/events/getVotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { FastifyPluginAsync } from 'fastify'
import { Type } from '@sinclair/typebox'
import { ErrorSchema, IdSchema } from '../../schemas'
import { authenticateRequest } from '../../plugins/apiKeyPlugin'
import { ProjectDao } from '../../dao/ProjectDao'
import { NotFoundError } from '../../others/Errors'
import {
buildEventVotesExport,
ExportableProject,
} from '../../services/exportEventVotes'

const ParamsSchema = Type.Object({
projectId: IdSchema,
})

const EventVotesResponseSchema = Type.Object({
projectId: IdSchema,
sessionsCount: Type.Integer({ minimum: 0 }),
// Each row has fixed session fields plus one column per vote item, so the
// shape is open (additionalProperties).
sessions: Type.Array(Type.Record(Type.String(), Type.Any())),
})

export const getEventVotesRoute: FastifyPluginAsync = async (server) => {
server.get(
'/:projectId/votes',
{
schema: {
description:
'Export all session votes for an event (project). ' +
'Accepts an event API key (`ofproj_`) for its own event, ' +
'or an organization key (`oforg_`) for any event in that ' +
'organization.',
tags: ['Events'],
params: ParamsSchema,
response: {
200: EventVotesResponseSchema,
400: ErrorSchema,
401: ErrorSchema,
404: ErrorSchema,
},
},
preHandler: authenticateRequest,
},
async (request) => {
const { projectId } = request.params as { projectId: string }

// Authorize against the authenticated key. Use 404 (not 403) on a
// mismatch so we never reveal which event ids exist.
let project: ExportableProject
if (request.project) {
if (request.project.id !== projectId) {
throw new NotFoundError('Event not found')
}
project = request.project as unknown as ExportableProject
} else if (request.organization) {
// Resolve the event and confirm it belongs to this org. A
// missing project and a wrong-org project must look identical
// (same 404 message) so event ids stay non-enumerable.
let resolved
try {
resolved = await ProjectDao.getProjectFromId(
server.firebase,
projectId
)
} catch (error) {
if (error instanceof NotFoundError) {
throw new NotFoundError('Event not found')
}
throw error
}
if (resolved.organizationId !== request.organization.id) {
throw new NotFoundError('Event not found')
}
project = resolved as unknown as ExportableProject
} else {
// authenticateRequest guarantees one of the two; defensive only.
throw new NotFoundError('Event not found')
}

const sessions = await buildEventVotesExport(
server.firebase,
project
)

return {
projectId,
sessionsCount: sessions.length,
sessions,
}
}
)
}
2 changes: 2 additions & 0 deletions functions/src/api/routes/events/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { FastifyPluginAsync } from 'fastify'
import { getEventByApiKeyRoute } from './getByApiKey'
import { getEventVotesRoute } from './getVotes'

export const eventsRoutes: FastifyPluginAsync = async (server) => {
await server.register(getEventByApiKeyRoute)
await server.register(getEventVotesRoute)
}
Loading
Loading