diff --git a/__tests__/actions/push.test.ts b/__tests__/actions/push.test.ts new file mode 100644 index 0000000..1e74a22 --- /dev/null +++ b/__tests__/actions/push.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +const { mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockDeleteMany: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + pushSubscription: { + upsert: mockUpsert, + deleteMany: mockDeleteMany, + }, + }, +})) + +import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push' + +const VALID_INPUT = { + endpoint: 'https://push.example.com/subscription/abc123', + keys: { p256dh: 'aBcDeFgH', auth: 'xYzAbC' }, +} + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } + +beforeEach(() => { + vi.clearAllMocks() + mockUpsert.mockResolvedValue({}) + mockDeleteMany.mockResolvedValue({ count: 1 }) +}) + +describe('subscribeToPushAction', () => { + it('upserts subscription for authenticated user', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { endpoint: VALID_INPUT.endpoint }, + create: expect.objectContaining({ user_id: 'user-1', endpoint: VALID_INPUT.endpoint }), + }) + ) + }) + + it('is idempotent — calling twice upserts twice without error', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + await subscribeToPushAction(VALID_INPUT) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).toHaveBeenCalledTimes(2) + }) + + it('returns without writing for demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('returns without writing when not authenticated', async () => { + mockGetSession.mockResolvedValue({}) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('returns without writing for invalid input', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + // @ts-expect-error intentionally invalid + await subscribeToPushAction({ endpoint: 'not-a-url', keys: {} }) + expect(mockUpsert).not.toHaveBeenCalled() + }) +}) + +describe('unsubscribeFromPushAction', () => { + it('deletes subscription scoped to user_id', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { endpoint: VALID_INPUT.endpoint, user_id: 'user-1' }, + }) + }) + + it('does not touch subscriptions of other users', async () => { + mockGetSession.mockResolvedValue({ userId: 'other-user', isDemo: false }) + await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) + expect(mockDeleteMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ user_id: 'other-user' }) }) + ) + }) + + it('returns without writing when not authenticated', async () => { + mockGetSession.mockResolvedValue({}) + await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) + expect(mockDeleteMany).not.toHaveBeenCalled() + }) +}) diff --git a/actions/push.ts b/actions/push.ts index 318f7dc..ec9a216 100644 --- a/actions/push.ts +++ b/actions/push.ts @@ -1,11 +1,52 @@ 'use server' -// Stub — full implementation added by ST-cmovs7t590009 (subscribeToPushAction + unsubscribeFromPushAction) +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' -export async function subscribeToPushAction(_sub: unknown): Promise { - throw new Error('Not implemented') +const subscribeSchema = z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string().min(1), + auth: z.string().min(1), + }), + userAgent: z.string().optional(), +}) + +export type SubscribeToPushInput = z.infer + +export async function subscribeToPushAction(input: SubscribeToPushInput): Promise { + const session = await getSession() + if (!session.userId) return + if (session.isDemo) return + + const parsed = subscribeSchema.safeParse(input) + if (!parsed.success) return + + const { endpoint, keys, userAgent } = parsed.data + await prisma.pushSubscription.upsert({ + where: { endpoint }, + create: { + user_id: session.userId, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + user_agent: userAgent ?? null, + }, + update: { + user_id: session.userId, + p256dh: keys.p256dh, + auth: keys.auth, + last_used_at: new Date(), + }, + }) } -export async function unsubscribeFromPushAction(_args: { endpoint: string }): Promise { - throw new Error('Not implemented') +export async function unsubscribeFromPushAction(args: { endpoint: string }): Promise { + const session = await getSession() + if (!session.userId) return + + await prisma.pushSubscription.deleteMany({ + where: { endpoint: args.endpoint, user_id: session.userId }, + }) }