diff --git a/pages/p/[orgSlug]/[projectSlug].vue b/pages/p/[orgSlug]/[projectSlug].vue index cd4bf6d..019fc82 100644 --- a/pages/p/[orgSlug]/[projectSlug].vue +++ b/pages/p/[orgSlug]/[projectSlug].vue @@ -121,6 +121,7 @@ v-for="item in feedbackItems" :key="item.id" class="rounded-lg border bg-card hover:shadow-sm transition-shadow" + :class="item.isOwn ? 'ring-2 ring-primary/30' : ''" >
@@ -162,7 +163,11 @@ {{ item.body }}

- + + + Your submission + + {{ item.authorName }} @@ -267,6 +272,9 @@ type="email" placeholder="you@example.com" /> +

+ Provide your email to receive updates on comments and status changes. +

@@ -428,16 +436,14 @@ export default { }, async handleVote(item) { - // Voting requires auth — redirect or show message try { const response = await $fetch(`/api/feedback/${item.id}/vote`, { method: 'POST' }) const data = response?.data item.voteCount = data.voteCount item.hasVoted = data.voted } catch (err) { - if (err?.statusCode === 401) { - alert('Please sign in to vote on feedback.') - } + console.error('Error voting:', err) + alert('Failed to vote. Please try again.') } }, diff --git a/server/api/auth/merge-anonymous.post.ts b/server/api/auth/merge-anonymous.post.ts new file mode 100644 index 0000000..38d31c4 --- /dev/null +++ b/server/api/auth/merge-anonymous.post.ts @@ -0,0 +1,108 @@ +/** + * POST /api/auth/merge-anonymous + * + * Called after login/signup to merge any anonymous feedback and votes + * into the authenticated user's account. Reads the `veerify_anon_session` + * cookie, transfers ownership of feedback and votes, then deletes the + * anonymous session and clears the cookie. + */ + +import { eq, and, sql } from 'drizzle-orm' +import { createSuccessResponse } from '~/server/utils/response' +import { requireAuth } from '~/server/utils/auth-middleware' +import { readAnonToken, clearAnonCookie } from '~/server/utils/anonymous-session' +import { db } from '~/server/database/drizzle' +import { anonymousSession, feedback, vote } from '~/server/database/schema/feedback' + +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const token = readAnonToken(event) + + if (!token) { + return createSuccessResponse({ merged: false, reason: 'no_anonymous_session' }) + } + + // Look up the anonymous session + const [anonRow] = await db + .select() + .from(anonymousSession) + .where(eq(anonymousSession.token, token)) + .limit(1) + + if (!anonRow) { + clearAnonCookie(event) + return createSuccessResponse({ merged: false, reason: 'session_not_found' }) + } + + const anonId = anonRow.id + const userId = session.user.id + + // 1. Transfer feedback ownership + await db + .update(feedback) + .set({ + authorUserId: userId, + authorSessionId: null, + updatedAt: new Date(), + }) + .where(eq(feedback.authorSessionId, anonId)) + + // 2. Transfer votes — but skip duplicates where the user already voted + // on the same feedback item. + // First, find anonymous votes that conflict with existing user votes. + const anonVotes = await db + .select({ id: vote.id, feedbackId: vote.feedbackId }) + .from(vote) + .where(eq(vote.voterSessionId, anonId)) + + const userVotes = await db + .select({ feedbackId: vote.feedbackId }) + .from(vote) + .where(eq(vote.voterUserId, userId)) + + const userVotedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId)) + + const duplicateVoteIds: string[] = [] + const transferVoteIds: string[] = [] + + for (const v of anonVotes) { + if (userVotedFeedbackIds.has(v.feedbackId)) { + duplicateVoteIds.push(v.id) + } else { + transferVoteIds.push(v.id) + } + } + + // Delete duplicate votes and decrement feedback vote counts + for (const dupId of duplicateVoteIds) { + const dupVote = anonVotes.find((v) => v.id === dupId)! + await db.delete(vote).where(eq(vote.id, dupId)) + await db + .update(feedback) + .set({ voteCount: sql`${feedback.voteCount} - 1`, updatedAt: new Date() }) + .where(eq(feedback.id, dupVote.feedbackId)) + } + + // Transfer non-duplicate votes to the user + if (transferVoteIds.length > 0) { + for (const transferId of transferVoteIds) { + await db + .update(vote) + .set({ voterUserId: userId, voterSessionId: null }) + .where(eq(vote.id, transferId)) + } + } + + // 3. Delete the anonymous session + await db.delete(anonymousSession).where(eq(anonymousSession.id, anonId)) + + // 4. Clear the cookie + clearAnonCookie(event) + + return createSuccessResponse({ + merged: true, + feedbackTransferred: anonVotes.length > 0 || transferVoteIds.length > 0, + votesTransferred: transferVoteIds.length, + duplicateVotesRemoved: duplicateVoteIds.length, + }) +}) diff --git a/server/api/feedback/[id]/vote.post.ts b/server/api/feedback/[id]/vote.post.ts index 28741ad..adb03b6 100644 --- a/server/api/feedback/[id]/vote.post.ts +++ b/server/api/feedback/[id]/vote.post.ts @@ -1,11 +1,12 @@ import { eq, and, sql } from 'drizzle-orm' import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' -import { requireAuth } from '~/server/utils/auth-middleware' +import { optionalAuth } from '~/server/utils/auth-middleware' +import { getOrCreateAnonSession } from '~/server/utils/anonymous-session' import { db } from '~/server/database/drizzle' import { feedback, vote } from '~/server/database/schema/feedback' export default defineEventHandler(async (event) => { - const session = await requireAuth(event) + const session = await optionalAuth(event) const id = getRouterParam(event, 'id') if (!id) { @@ -26,15 +27,24 @@ export default defineEventHandler(async (event) => { }) } - // Check if user already voted - const [existingVote] = await db - .select() - .from(vote) - .where(and(eq(vote.feedbackId, id), eq(vote.voterUserId, session.user.id))) - .limit(1) + // Determine voter identity: authenticated user or anonymous session + const userId = session?.user?.id || null + let anonSessionId: string | null = null + + if (!userId) { + const anonSession = await getOrCreateAnonSession(event) + anonSessionId = anonSession.id + } + + // Build the condition for finding an existing vote + const voteCondition = userId + ? and(eq(vote.feedbackId, id), eq(vote.voterUserId, userId)) + : and(eq(vote.feedbackId, id), eq(vote.voterSessionId, anonSessionId!)) + + const [existingVote] = await db.select().from(vote).where(voteCondition).limit(1) if (existingVote) { - // Remove vote + // Remove vote (toggle off) await db.delete(vote).where(eq(vote.id, existingVote.id)) await db .update(feedback) @@ -48,7 +58,8 @@ export default defineEventHandler(async (event) => { await db.insert(vote).values({ id: crypto.randomUUID(), feedbackId: id, - voterUserId: session.user.id, + voterUserId: userId, + voterSessionId: anonSessionId, createdAt: new Date(), }) await db diff --git a/server/api/feedback/index.get.ts b/server/api/feedback/index.get.ts index 5648ccf..01ae235 100644 --- a/server/api/feedback/index.get.ts +++ b/server/api/feedback/index.get.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { eq, and, desc, asc, count, sql } from 'drizzle-orm' import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' import { optionalAuth } from '~/server/utils/auth-middleware' +import { getAnonSession } from '~/server/utils/anonymous-session' import { validateQuery } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { feedback, feedbackCategory, vote } from '~/server/database/schema/feedback' @@ -20,6 +21,7 @@ const listFeedbackQuerySchema = z.object({ export default defineEventHandler(async (event) => { const session = await optionalAuth(event) + const anonSession = !session?.user ? await getAnonSession(event) : null const query = validateQuery(event, listFeedbackQuerySchema) // Build conditions @@ -61,23 +63,34 @@ export default defineEventHandler(async (event) => { .limit(query.limit) .offset(offset) - // If authenticated, check which items the user has voted on - let userVotes: Set = new Set() - if (session?.user) { - const feedbackIds = items.map((i) => i.feedback.id) - if (feedbackIds.length > 0) { + // Check which items the viewer has voted on (authenticated user or anonymous session) + let voterVotes: Set = new Set() + const feedbackIds = items.map((i) => i.feedback.id) + if (feedbackIds.length > 0) { + if (session?.user) { const votes = await db .select({ feedbackId: vote.feedbackId }) .from(vote) - .where(and(eq(vote.voterUserId, session.user.id))) - userVotes = new Set(votes.map((v) => v.feedbackId)) + .where(eq(vote.voterUserId, session.user.id)) + voterVotes = new Set(votes.map((v) => v.feedbackId)) + } else if (anonSession) { + const votes = await db + .select({ feedbackId: vote.feedbackId }) + .from(vote) + .where(eq(vote.voterSessionId, anonSession.id)) + voterVotes = new Set(votes.map((v) => v.feedbackId)) } } const result = items.map((item) => ({ ...item.feedback, category: item.category, - hasVoted: session?.user ? userVotes.has(item.feedback.id) : undefined, + hasVoted: voterVotes.has(item.feedback.id), + isOwn: session?.user + ? item.feedback.authorUserId === session.user.id + : anonSession + ? item.feedback.authorSessionId === anonSession.id + : false, })) return createSuccessResponse({ diff --git a/server/api/feedback/index.post.ts b/server/api/feedback/index.post.ts index 4f3168e..e2f8edf 100644 --- a/server/api/feedback/index.post.ts +++ b/server/api/feedback/index.post.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { eq } from 'drizzle-orm' import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' import { optionalAuth } from '~/server/utils/auth-middleware' +import { getOrCreateAnonSession } from '~/server/utils/anonymous-session' import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { feedback, project, feedbackCategory } from '~/server/database/schema/feedback' @@ -64,6 +65,13 @@ export default defineEventHandler(async (event) => { }) } + // For anonymous users, get or create an anonymous session + let anonSessionId: string | null = null + if (!session?.user) { + const anonSession = await getOrCreateAnonSession(event) + anonSessionId = anonSession.id + } + const now = new Date() const [created] = await db .insert(feedback) @@ -75,6 +83,7 @@ export default defineEventHandler(async (event) => { body: body.body, status: 'open', authorUserId: session?.user?.id || null, + authorSessionId: anonSessionId, authorName: session?.user ? session.user.name : body.authorName || null, authorEmail: session?.user ? session.user.email : body.authorEmail || null, voteCount: 0, diff --git a/server/api/public/[orgSlug]/[projectSlug]/feedback.get.ts b/server/api/public/[orgSlug]/[projectSlug]/feedback.get.ts index 74dafd8..cf88c2f 100644 --- a/server/api/public/[orgSlug]/[projectSlug]/feedback.get.ts +++ b/server/api/public/[orgSlug]/[projectSlug]/feedback.get.ts @@ -1,9 +1,11 @@ import { z } from 'zod' import { eq, and, desc, asc, count } from 'drizzle-orm' import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' +import { optionalAuth } from '~/server/utils/auth-middleware' +import { getAnonSession } from '~/server/utils/anonymous-session' import { validateQuery } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' -import { project, feedback, feedbackCategory } from '~/server/database/schema/feedback' +import { project, feedback, feedbackCategory, vote } from '~/server/database/schema/feedback' import { organization } from '~/server/database/schema/auth' const querySchema = z.object({ @@ -27,6 +29,8 @@ export default defineEventHandler(async (event) => { }) } + const session = await optionalAuth(event) + const anonSession = !session?.user ? await getAnonSession(event) : null const query = validateQuery(event, querySchema) // Resolve org + project @@ -81,6 +85,25 @@ export default defineEventHandler(async (event) => { .limit(query.limit) .offset(offset) + // Check which items the viewer has voted on (authenticated user or anonymous session) + let voterVotes: Set = new Set() + const feedbackIds = items.map((i) => i.feedback.id) + if (feedbackIds.length > 0) { + if (session?.user) { + const votes = await db + .select({ feedbackId: vote.feedbackId }) + .from(vote) + .where(eq(vote.voterUserId, session.user.id)) + voterVotes = new Set(votes.map((v) => v.feedbackId)) + } else if (anonSession) { + const votes = await db + .select({ feedbackId: vote.feedbackId }) + .from(vote) + .where(eq(vote.voterSessionId, anonSession.id)) + voterVotes = new Set(votes.map((v) => v.feedbackId)) + } + } + const result = items.map((item) => ({ id: item.feedback.id, title: item.feedback.title, @@ -91,6 +114,12 @@ export default defineEventHandler(async (event) => { authorName: item.feedback.authorName, isPinned: item.feedback.isPinned, createdAt: item.feedback.createdAt, + hasVoted: voterVotes.has(item.feedback.id), + isOwn: session?.user + ? item.feedback.authorUserId === session.user.id + : anonSession + ? item.feedback.authorSessionId === anonSession.id + : false, category: item.category ? { id: item.category.id, diff --git a/server/api/public/[orgSlug]/[projectSlug]/feedback.post.ts b/server/api/public/[orgSlug]/[projectSlug]/feedback.post.ts index fa4012c..f869c84 100644 --- a/server/api/public/[orgSlug]/[projectSlug]/feedback.post.ts +++ b/server/api/public/[orgSlug]/[projectSlug]/feedback.post.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { eq, and } from 'drizzle-orm' import { createErrorResponse, createSuccessResponse, ErrorCode } from '~/server/utils/response' import { optionalAuth } from '~/server/utils/auth-middleware' +import { getOrCreateAnonSession } from '~/server/utils/anonymous-session' import { validateBody } from '~/server/utils/validation' import { db } from '~/server/database/drizzle' import { project, feedback, feedbackCategory } from '~/server/database/schema/feedback' @@ -79,6 +80,13 @@ export default defineEventHandler(async (event) => { }) } + // For anonymous users, get or create an anonymous session + let anonSessionId: string | null = null + if (!session?.user) { + const anonSession = await getOrCreateAnonSession(event) + anonSessionId = anonSession.id + } + const now = new Date() const [created] = await db .insert(feedback) @@ -90,6 +98,7 @@ export default defineEventHandler(async (event) => { body: body.body, status: 'open', authorUserId: session?.user?.id || null, + authorSessionId: anonSessionId, authorName: session?.user ? session.user.name : body.authorName || null, authorEmail: session?.user ? session.user.email : body.authorEmail || null, voteCount: 0, diff --git a/server/utils/anonymous-session.ts b/server/utils/anonymous-session.ts new file mode 100644 index 0000000..cc4bbb3 --- /dev/null +++ b/server/utils/anonymous-session.ts @@ -0,0 +1,122 @@ +/** + * Anonymous session management for unauthenticated feedback actions. + * + * On first feedback action (create or vote), a UUID token is generated, + * stored in an HttpOnly cookie (`veerify_anon_session`), and persisted + * in the `anonymous_session` table. The session survives browser restarts + * and expires after 90 days of inactivity. + */ + +import type { H3Event } from 'h3' +import { eq, and, gt } from 'drizzle-orm' +import { db } from '~/server/database/drizzle' +import { anonymousSession } from '~/server/database/schema/feedback' + +const COOKIE_NAME = 'veerify_anon_session' +const SESSION_TTL_DAYS = 90 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +function getExpiresAt(): Date { + return new Date(Date.now() + SESSION_TTL_DAYS * MS_PER_DAY) +} + +/** + * Reads the anonymous session token from the request cookie. + * Returns null if no cookie is set. + */ +export function readAnonToken(event: H3Event): string | null { + return getCookie(event, COOKIE_NAME) || null +} + +/** + * Sets the anonymous session cookie on the response. + */ +function setAnonCookie(event: H3Event, token: string, expiresAt: Date) { + setCookie(event, COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + expires: expiresAt, + }) +} + +/** + * Clears the anonymous session cookie. + */ +export function clearAnonCookie(event: H3Event) { + deleteCookie(event, COOKIE_NAME, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }) +} + +export interface AnonSession { + id: string + token: string +} + +/** + * Looks up a valid (non-expired) anonymous session by token. + * If found, refreshes `lastSeenAt` and cookie expiry. + * Returns null if the session does not exist or has expired. + */ +export async function getAnonSession(event: H3Event): Promise { + const token = readAnonToken(event) + if (!token) return null + + const [row] = await db + .select({ id: anonymousSession.id, token: anonymousSession.token }) + .from(anonymousSession) + .where(and(eq(anonymousSession.token, token), gt(anonymousSession.expiresAt, new Date()))) + .limit(1) + + if (!row) return null + + // Refresh the session activity window + const newExpiry = getExpiresAt() + await db + .update(anonymousSession) + .set({ lastSeenAt: new Date(), expiresAt: newExpiry }) + .where(eq(anonymousSession.id, row.id)) + + setAnonCookie(event, row.token, newExpiry) + + return { id: row.id, token: row.token } +} + +/** + * Returns the existing anonymous session or creates a new one. + * Always sets/refreshes the HttpOnly cookie. + */ +export async function getOrCreateAnonSession(event: H3Event): Promise { + const existing = await getAnonSession(event) + if (existing) return existing + + const id = crypto.randomUUID() + const token = crypto.randomUUID() + const now = new Date() + const expiresAt = getExpiresAt() + + const ipAddress = + getHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || + event.node.req.socket?.remoteAddress || + null + const userAgent = getHeader(event, 'user-agent') || null + + await db.insert(anonymousSession).values({ + id, + token, + ipAddress, + userAgent, + createdAt: now, + lastSeenAt: now, + expiresAt, + }) + + setAnonCookie(event, token, expiresAt) + + return { id, token } +} diff --git a/tests/e2e/anonymous-feedback.spec.ts b/tests/e2e/anonymous-feedback.spec.ts new file mode 100644 index 0000000..d1c9f83 --- /dev/null +++ b/tests/e2e/anonymous-feedback.spec.ts @@ -0,0 +1,263 @@ +import { expect, test, type Page, type APIRequestContext } from '@playwright/test' +import { loginViaUi } from './helpers/auth' + +/** + * Anonymous feedback e2e tests. + * + * These tests exercise the public feedback page at /p/preview-org/demo + * which is seeded by the db:seed script. The anonymous session is managed + * through an HttpOnly cookie `veerify_anon_session`. + */ + +const PUBLIC_PAGE = '/p/preview-org/demo' +const TEST_EMAIL = process.env.E2E_USER_EMAIL || 'test@preview.local' +const TEST_PASSWORD = process.env.E2E_USER_PASSWORD || 'password123' + +test.setTimeout(120_000) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function waitForPageReady(page: Page) { + // Wait for the public feedback page to load project data + await page.waitForSelector('h1', { timeout: 30_000 }) +} + +async function openSubmitDialog(page: Page) { + const submitBtn = page.getByRole('button', { name: 'Submit Feedback' }) + await submitBtn.first().click() + await expect(page.getByText('Share your ideas')).toBeVisible({ timeout: 5_000 }) +} + +async function fillAndSubmitFeedback( + page: Page, + opts: { title: string; body: string; name: string; email?: string } +) { + await openSubmitDialog(page) + + await page.locator('#fb-title').fill(opts.title) + await page.locator('#fb-body').fill(opts.body) + await page.locator('#fb-name').fill(opts.name) + if (opts.email) { + await page.locator('#fb-email').fill(opts.email) + } + + // Click the submit button inside the dialog + const dialogSubmit = page.locator('[role="dialog"] button', { hasText: 'Submit' }).last() + await dialogSubmit.click() + + // Wait for the dialog to close and feedback list to reload + await expect(page.locator('[role="dialog"]')).not.toBeVisible({ timeout: 15_000 }) +} + +function getAnonCookie(page: Page) { + return page.context().cookies().then((cookies) => cookies.find((c) => c.name === 'veerify_anon_session')) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe('Anonymous feedback sessions', () => { + test('public feedback page loads for unauthenticated users', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + // Project header should render + await expect(page.getByRole('heading', { name: 'Demo Project' })).toBeVisible() + // Submit Feedback button should be visible + await expect(page.getByRole('button', { name: 'Submit Feedback' }).first()).toBeVisible() + }) + + test('anonymous user can submit feedback and gets a session cookie', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + // No anonymous cookie before submission + let cookie = await getAnonCookie(page) + expect(cookie).toBeUndefined() + + const title = `Anon Feedback ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'This is anonymous feedback submitted during e2e testing.', + name: 'E2E Anon User', + }) + + // After submission, the anonymous session cookie should be set + cookie = await getAnonCookie(page) + expect(cookie).toBeDefined() + expect(cookie!.httpOnly).toBe(true) + expect(cookie!.path).toBe('/') + + // The submitted feedback should appear in the list + await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 }) + }) + + test('anonymous user can submit feedback with optional email', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + const title = `Anon With Email ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'Feedback with optional email for notifications.', + name: 'E2E Email User', + email: 'anon@example.com', + }) + + await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 }) + }) + + test('anonymous user sees own submissions highlighted', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + const title = `Own Submission ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'Testing own submission highlighting.', + name: 'Highlight User', + }) + + // The feedback card should have the "Your submission" badge + await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 }) + // Find the card containing our title and check for the highlight + const card = page.locator('.space-y-3 > div', { hasText: title }).first() + await expect(card.getByText('Your submission')).toBeVisible() + }) + + test('anonymous user can vote on feedback', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + // First submit a feedback item to ensure there's something to vote on + const title = `Votable Feedback ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'Testing anonymous voting.', + name: 'Vote Tester', + }) + + await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 }) + + // Click the vote button on the first feedback card + const feedbackCard = page.locator('.space-y-3 > div', { hasText: title }).first() + const voteButton = feedbackCard.locator('button').first() + + // Get initial vote count text + const voteCountEl = voteButton.locator('span') + const initialCount = parseInt((await voteCountEl.textContent()) || '0', 10) + + // Click to vote (upvote) + await voteButton.click() + await page.waitForTimeout(1000) // Wait for API response + + // Vote count should have increased + const newCount = parseInt((await voteCountEl.textContent()) || '0', 10) + expect(newCount).toBe(initialCount + 1) + }) + + test('anonymous session cookie persists across page reloads', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + // Submit feedback to create session + const title = `Persist Test ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'Testing session persistence.', + name: 'Persist User', + }) + + // Get the cookie token + const cookie1 = await getAnonCookie(page) + expect(cookie1).toBeDefined() + const token1 = cookie1!.value + + // Reload the page + await page.reload() + await waitForPageReady(page) + + // Cookie should still be present with the same token + const cookie2 = await getAnonCookie(page) + expect(cookie2).toBeDefined() + expect(cookie2!.value).toBe(token1) + + // Own submissions should still be highlighted after reload + await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 }) + }) + + test('anonymous session merges into authenticated user on login', async ({ page }) => { + // Start as anonymous + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + const title = `Merge Test ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'Testing session merge on authentication.', + name: 'Merge User', + }) + + // Verify anonymous cookie exists + let cookie = await getAnonCookie(page) + expect(cookie).toBeDefined() + + // Now log in + await loginViaUi(page, { email: TEST_EMAIL, password: TEST_PASSWORD }) + + // Trigger the merge + await page.evaluate(async () => { + await fetch('/api/auth/merge-anonymous', { method: 'POST' }) + }) + + // After merge, the anonymous cookie should be cleared + cookie = await getAnonCookie(page) + expect(cookie).toBeUndefined() + }) + + test('vote toggle works for anonymous users (vote then unvote)', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + // Submit feedback + const title = `Toggle Vote ${Date.now()}` + await fillAndSubmitFeedback(page, { + title, + body: 'Testing vote toggle.', + name: 'Toggle User', + }) + + await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 }) + + const feedbackCard = page.locator('.space-y-3 > div', { hasText: title }).first() + const voteButton = feedbackCard.locator('button').first() + const voteCountEl = voteButton.locator('span') + + const initialCount = parseInt((await voteCountEl.textContent()) || '0', 10) + + // Vote + await voteButton.click() + await page.waitForTimeout(1000) + expect(parseInt((await voteCountEl.textContent()) || '0', 10)).toBe(initialCount + 1) + + // Unvote (toggle off) + await voteButton.click() + await page.waitForTimeout(1000) + expect(parseInt((await voteCountEl.textContent()) || '0', 10)).toBe(initialCount) + }) + + test('email helper text is shown in submit dialog', async ({ page }) => { + await page.goto(PUBLIC_PAGE) + await waitForPageReady(page) + + await openSubmitDialog(page) + + // Check that the email helper text exists + await expect( + page.getByText('Provide your email to receive updates on comments and status changes.') + ).toBeVisible() + }) +})