Skip to content

Commit 86fdcc7

Browse files
authored
Merge pull request #205 from mchestr/feature/announcements-and-dashboard-improvements
feat: add announcements system and improve user dashboard layout
2 parents 0d5d03c + 8244e03 commit 86fdcc7

File tree

17 files changed

+1924
-100
lines changed

17 files changed

+1924
-100
lines changed
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
/**
2+
* Tests for actions/announcements.ts - announcement CRUD operations
3+
*
4+
* These tests cover:
5+
* - Getting active announcements (filters expired/inactive)
6+
* - Admin-only operations (create, update, delete, setActive)
7+
* - Input validation with Zod
8+
* - Prisma error handling (P2025 not found)
9+
*/
10+
11+
import {
12+
getActiveAnnouncements,
13+
getAllAnnouncements,
14+
createAnnouncement,
15+
updateAnnouncement,
16+
deleteAnnouncement,
17+
setAnnouncementActive,
18+
} from '@/actions/announcements'
19+
import { prisma } from '@/lib/prisma'
20+
import { getServerSession } from 'next-auth'
21+
import { revalidatePath } from 'next/cache'
22+
23+
// Mock dependencies
24+
jest.mock('@/lib/prisma', () => ({
25+
prisma: {
26+
announcement: {
27+
findMany: jest.fn(),
28+
findUnique: jest.fn(),
29+
create: jest.fn(),
30+
update: jest.fn(),
31+
delete: jest.fn(),
32+
},
33+
},
34+
}))
35+
36+
jest.mock('next-auth', () => ({
37+
getServerSession: jest.fn(),
38+
}))
39+
40+
jest.mock('next/cache', () => ({
41+
revalidatePath: jest.fn(),
42+
}))
43+
44+
jest.mock('@/lib/auth', () => ({
45+
authOptions: {},
46+
}))
47+
48+
const mockPrisma = prisma as jest.Mocked<typeof prisma>
49+
const mockGetServerSession = getServerSession as jest.MockedFunction<typeof getServerSession>
50+
const mockRevalidatePath = revalidatePath as jest.MockedFunction<typeof revalidatePath>
51+
52+
describe('announcements actions', () => {
53+
const mockAdminSession = {
54+
user: { id: 'admin-123', name: 'Admin', email: '[email protected]', isAdmin: true },
55+
expires: new Date(Date.now() + 86400000).toISOString(),
56+
}
57+
58+
const mockNonAdminSession = {
59+
user: { id: 'user-123', name: 'User', email: '[email protected]', isAdmin: false },
60+
expires: new Date(Date.now() + 86400000).toISOString(),
61+
}
62+
63+
const mockAnnouncement = {
64+
id: 'ann-123',
65+
title: 'Test Announcement',
66+
content: 'This is test content',
67+
priority: 10,
68+
isActive: true,
69+
createdAt: new Date('2024-01-01'),
70+
expiresAt: new Date('2025-12-31'),
71+
createdBy: 'admin-123',
72+
updatedAt: new Date('2024-01-01'),
73+
}
74+
75+
beforeEach(() => {
76+
jest.clearAllMocks()
77+
})
78+
79+
describe('getActiveAnnouncements', () => {
80+
it('should return active, non-expired announcements', async () => {
81+
mockPrisma.announcement.findMany.mockResolvedValue([mockAnnouncement])
82+
83+
const result = await getActiveAnnouncements()
84+
85+
expect(result).toHaveLength(1)
86+
expect(result[0].id).toBe('ann-123')
87+
expect(result[0].title).toBe('Test Announcement')
88+
expect(mockPrisma.announcement.findMany).toHaveBeenCalledWith(
89+
expect.objectContaining({
90+
where: expect.objectContaining({
91+
isActive: true,
92+
}),
93+
})
94+
)
95+
})
96+
97+
it('should return empty array on database error', async () => {
98+
mockPrisma.announcement.findMany.mockRejectedValue(new Error('Database error'))
99+
100+
const result = await getActiveAnnouncements()
101+
102+
expect(result).toEqual([])
103+
})
104+
105+
it('should convert dates to ISO strings', async () => {
106+
mockPrisma.announcement.findMany.mockResolvedValue([mockAnnouncement])
107+
108+
const result = await getActiveAnnouncements()
109+
110+
expect(result[0].createdAt).toBe('2024-01-01T00:00:00.000Z')
111+
expect(result[0].expiresAt).toBe('2025-12-31T00:00:00.000Z')
112+
})
113+
})
114+
115+
describe('getAllAnnouncements', () => {
116+
it('should return all announcements for admin', async () => {
117+
mockGetServerSession.mockResolvedValue(mockAdminSession)
118+
mockPrisma.announcement.findMany.mockResolvedValue([mockAnnouncement])
119+
120+
const result = await getAllAnnouncements()
121+
122+
expect(result).toHaveLength(1)
123+
expect(result[0].id).toBe('ann-123')
124+
})
125+
126+
it('should return empty array for non-admin', async () => {
127+
mockGetServerSession.mockResolvedValue(mockNonAdminSession)
128+
129+
const result = await getAllAnnouncements()
130+
131+
expect(result).toEqual([])
132+
expect(mockPrisma.announcement.findMany).not.toHaveBeenCalled()
133+
})
134+
135+
it('should return empty array when not authenticated', async () => {
136+
mockGetServerSession.mockResolvedValue(null)
137+
138+
const result = await getAllAnnouncements()
139+
140+
expect(result).toEqual([])
141+
})
142+
})
143+
144+
describe('createAnnouncement', () => {
145+
it('should create announcement for admin', async () => {
146+
mockGetServerSession.mockResolvedValue(mockAdminSession)
147+
mockPrisma.announcement.create.mockResolvedValue(mockAnnouncement)
148+
149+
const result = await createAnnouncement({
150+
title: 'Test Announcement',
151+
content: 'This is test content',
152+
priority: 10,
153+
isActive: true,
154+
expiresAt: '2025-12-31T00:00:00.000Z',
155+
})
156+
157+
expect(result.success).toBe(true)
158+
expect(result.data?.title).toBe('Test Announcement')
159+
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
160+
expect(mockRevalidatePath).toHaveBeenCalledWith('/admin/announcements')
161+
})
162+
163+
it('should reject for non-admin', async () => {
164+
mockGetServerSession.mockResolvedValue(mockNonAdminSession)
165+
166+
const result = await createAnnouncement({
167+
title: 'Test',
168+
content: 'Content',
169+
})
170+
171+
expect(result.success).toBe(false)
172+
expect(result.error).toBe('Unauthorized')
173+
})
174+
175+
it('should validate title is required', async () => {
176+
mockGetServerSession.mockResolvedValue(mockAdminSession)
177+
178+
const result = await createAnnouncement({
179+
title: '',
180+
content: 'Content',
181+
})
182+
183+
expect(result.success).toBe(false)
184+
expect(result.error).toBe('Title is required')
185+
})
186+
187+
it('should validate content is required', async () => {
188+
mockGetServerSession.mockResolvedValue(mockAdminSession)
189+
190+
const result = await createAnnouncement({
191+
title: 'Title',
192+
content: '',
193+
})
194+
195+
expect(result.success).toBe(false)
196+
expect(result.error).toBe('Content is required')
197+
})
198+
199+
it('should validate title max length', async () => {
200+
mockGetServerSession.mockResolvedValue(mockAdminSession)
201+
202+
const result = await createAnnouncement({
203+
title: 'a'.repeat(201),
204+
content: 'Content',
205+
})
206+
207+
expect(result.success).toBe(false)
208+
expect(result.error).toBe('Title too long')
209+
})
210+
211+
it('should validate invalid date format', async () => {
212+
mockGetServerSession.mockResolvedValue(mockAdminSession)
213+
214+
const result = await createAnnouncement({
215+
title: 'Title',
216+
content: 'Content',
217+
expiresAt: 'invalid-date',
218+
})
219+
220+
expect(result.success).toBe(false)
221+
expect(result.error).toBe('Invalid date format')
222+
})
223+
224+
it('should allow null expiresAt', async () => {
225+
mockGetServerSession.mockResolvedValue(mockAdminSession)
226+
mockPrisma.announcement.create.mockResolvedValue({
227+
...mockAnnouncement,
228+
expiresAt: null,
229+
})
230+
231+
const result = await createAnnouncement({
232+
title: 'Test',
233+
content: 'Content',
234+
expiresAt: null,
235+
})
236+
237+
expect(result.success).toBe(true)
238+
expect(result.data?.expiresAt).toBeNull()
239+
})
240+
})
241+
242+
describe('updateAnnouncement', () => {
243+
it('should update announcement for admin', async () => {
244+
mockGetServerSession.mockResolvedValue(mockAdminSession)
245+
mockPrisma.announcement.update.mockResolvedValue(mockAnnouncement)
246+
247+
const result = await updateAnnouncement({
248+
id: 'ann-123',
249+
title: 'Updated Title',
250+
content: 'Updated content',
251+
priority: 5,
252+
isActive: true,
253+
expiresAt: null,
254+
})
255+
256+
expect(result.success).toBe(true)
257+
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
258+
})
259+
260+
it('should return error for non-existent announcement', async () => {
261+
mockGetServerSession.mockResolvedValue(mockAdminSession)
262+
mockPrisma.announcement.update.mockRejectedValue({ code: 'P2025' })
263+
264+
const result = await updateAnnouncement({
265+
id: 'non-existent',
266+
title: 'Title',
267+
content: 'Content',
268+
priority: 0,
269+
isActive: true,
270+
})
271+
272+
expect(result.success).toBe(false)
273+
expect(result.error).toBe('Announcement not found')
274+
})
275+
276+
it('should reject for non-admin', async () => {
277+
mockGetServerSession.mockResolvedValue(mockNonAdminSession)
278+
279+
const result = await updateAnnouncement({
280+
id: 'ann-123',
281+
title: 'Title',
282+
content: 'Content',
283+
priority: 0,
284+
isActive: true,
285+
})
286+
287+
expect(result.success).toBe(false)
288+
expect(result.error).toBe('Unauthorized')
289+
})
290+
})
291+
292+
describe('deleteAnnouncement', () => {
293+
it('should delete announcement for admin', async () => {
294+
mockGetServerSession.mockResolvedValue(mockAdminSession)
295+
mockPrisma.announcement.delete.mockResolvedValue(mockAnnouncement)
296+
297+
const result = await deleteAnnouncement('ann-123')
298+
299+
expect(result.success).toBe(true)
300+
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
301+
expect(mockRevalidatePath).toHaveBeenCalledWith('/admin/announcements')
302+
})
303+
304+
it('should return error for non-existent announcement', async () => {
305+
mockGetServerSession.mockResolvedValue(mockAdminSession)
306+
mockPrisma.announcement.delete.mockRejectedValue({ code: 'P2025' })
307+
308+
const result = await deleteAnnouncement('non-existent')
309+
310+
expect(result.success).toBe(false)
311+
expect(result.error).toBe('Announcement not found')
312+
})
313+
314+
it('should reject for non-admin', async () => {
315+
mockGetServerSession.mockResolvedValue(mockNonAdminSession)
316+
317+
const result = await deleteAnnouncement('ann-123')
318+
319+
expect(result.success).toBe(false)
320+
expect(result.error).toBe('Unauthorized')
321+
})
322+
})
323+
324+
describe('setAnnouncementActive', () => {
325+
it('should set announcement to active', async () => {
326+
mockGetServerSession.mockResolvedValue(mockAdminSession)
327+
mockPrisma.announcement.update.mockResolvedValue({
328+
...mockAnnouncement,
329+
isActive: true,
330+
})
331+
332+
const result = await setAnnouncementActive('ann-123', true)
333+
334+
expect(result.success).toBe(true)
335+
expect(mockPrisma.announcement.update).toHaveBeenCalledWith({
336+
where: { id: 'ann-123' },
337+
data: { isActive: true },
338+
})
339+
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
340+
})
341+
342+
it('should set announcement to inactive', async () => {
343+
mockGetServerSession.mockResolvedValue(mockAdminSession)
344+
mockPrisma.announcement.update.mockResolvedValue({
345+
...mockAnnouncement,
346+
isActive: false,
347+
})
348+
349+
const result = await setAnnouncementActive('ann-123', false)
350+
351+
expect(result.success).toBe(true)
352+
expect(mockPrisma.announcement.update).toHaveBeenCalledWith({
353+
where: { id: 'ann-123' },
354+
data: { isActive: false },
355+
})
356+
})
357+
358+
it('should return error for non-existent announcement', async () => {
359+
mockGetServerSession.mockResolvedValue(mockAdminSession)
360+
mockPrisma.announcement.update.mockRejectedValue({ code: 'P2025' })
361+
362+
const result = await setAnnouncementActive('non-existent', true)
363+
364+
expect(result.success).toBe(false)
365+
expect(result.error).toBe('Announcement not found')
366+
})
367+
368+
it('should reject for non-admin', async () => {
369+
mockGetServerSession.mockResolvedValue(mockNonAdminSession)
370+
371+
const result = await setAnnouncementActive('ann-123', true)
372+
373+
expect(result.success).toBe(false)
374+
expect(result.error).toBe('Unauthorized')
375+
})
376+
})
377+
})

0 commit comments

Comments
 (0)