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
89 changes: 89 additions & 0 deletions src/app/api/communities/submit/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const getServerSession = vi.fn();
const saveSubmission = vi.fn();

vi.mock('next-auth', () => ({
getServerSession,
}));

vi.mock('@/lib/auth', () => ({
authOptions: {},
}));

vi.mock('@/lib/redis-community-submissions', () => ({
saveSubmission,
}));

describe('POST /api/communities/submit', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
getServerSession.mockResolvedValue({
user: { email: 'organizer@example.com' },
});
saveSubmission.mockResolvedValue(undefined);
});

it('returns the generated submission ID on successful submission', async () => {
const randomUUID = vi.spyOn(crypto, 'randomUUID').mockReturnValue('abc-123');
const { POST } = await import('./route');

const request = new Request('http://localhost/api/communities/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'React Michigan',
address: '123 Main St',
city: 'Detroit',
country: 'United States',
description: 'A React meetup in Michigan',
organizer_name: 'Ada Lovelace',
organizer_email: 'ada@example.com',
}),
});

const response = await POST(request);
const data = await response.json();

expect(response.status).toBe(200);
expect(data).toMatchObject({
success: true,
message: 'Community submitted successfully. We will review and add it soon!',
submissionId: 'submission-abc-123',
});
expect(saveSubmission).toHaveBeenCalledWith(
expect.objectContaining({
id: 'submission-abc-123',
submitted_by: 'organizer@example.com',
})
);

randomUUID.mockRestore();
});

it('keeps the existing failure payload when saving fails', async () => {
saveSubmission.mockRejectedValue(new Error('redis unavailable'));
const { POST } = await import('./route');

const request = new Request('http://localhost/api/communities/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'React Michigan',
address: '123 Main St',
city: 'Detroit',
country: 'United States',
description: 'A React meetup in Michigan',
organizer_name: 'Ada Lovelace',
organizer_email: 'ada@example.com',
}),
});

const response = await POST(request);
const data = await response.json();

expect(response.status).toBe(500);
expect(data).toEqual({ success: false, error: 'Submission failed' });
});
});
1 change: 1 addition & 0 deletions src/app/api/communities/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export async function POST(request: Request) {
return NextResponse.json({
success: true,
message: 'Community submitted successfully. We will review and add it soon!',
submissionId: submission.id,
});

} catch (error: unknown) {
Expand Down
164 changes: 164 additions & 0 deletions src/components/communities/AddCommunityForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import React from 'react';
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot } from 'react-dom/client';

const push = vi.fn();
const mockUseSession = vi.fn();

vi.mock('next-auth/react', () => ({
useSession: () => mockUseSession(),
}));

vi.mock('next/navigation', () => ({
useRouter: () => ({ push }),
}));

vi.mock('@/components/rfds', () => ({
RFDS: {
Input: ({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input className={className} {...props} />,
Textarea: ({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea className={className} {...props} />,
SemanticButton: ({ children, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button className={className} {...props}>
{children}
</button>
),
},
}));

vi.mock('@/components/ui/country-select', () => ({
CountrySelect: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<label>
Country
<select aria-label="Country" value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">Select a country</option>
<option value="United States">United States</option>
</select>
</label>
),
}));

describe('AddCommunityForm', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;

beforeEach(() => {
vi.useFakeTimers();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
push.mockReset();
mockUseSession.mockReturnValue({
data: {
user: { email: 'organizer@example.com' },
},
});
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
submissionId: 'submission-abc-123',
}),
} as Response);
container = document.createElement('div');
document.body.appendChild(container);
root = null;
});

afterEach(() => {
act(() => {
root?.unmount();
});
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('shows the confirmation ID returned by the API after a successful submission', async () => {
const { AddCommunityForm } = await import('./AddCommunityForm');
root = createRoot(container);

await act(async () => {
root!.render(<AddCommunityForm />);
});

fillField('Community Name *', 'React Michigan');
fillField('Full Address *', '123 Main St');
fillField('City *', 'Detroit');
fillField('Description *', 'A React meetup in Michigan');
fillField('Your Name *', 'Ada Lovelace');
fillField('Email *', 'ada@example.com');
setSelect('Country', 'United States');

await act(async () => {
container.querySelector('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});

expect(await screenText()).toContain('Community submitted!');
expect(await screenText()).toContain('Confirmation ID:');
expect(await screenText()).toContain('submission-abc-123');
});
});

function fillField(labelText: string, value: string) {
const label = [...document.querySelectorAll('label')].find((node) =>
node.textContent?.replace(/\s+/g, ' ').includes(labelText)
);

if (!label) {
throw new Error(`Label not found: ${labelText}`);
}

const field = label.parentElement?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null;

if (!field) {
throw new Error(`Field not found for label: ${labelText}`);
}

act(() => {
field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
});
}

function setSelect(labelText: string, value: string) {
const label = [...document.querySelectorAll('label')].find((node) =>
node.textContent?.replace(/\s+/g, ' ').includes(labelText)
);

if (!label) {
throw new Error(`Select label not found: ${labelText}`);
}

const select = label.querySelector('select');

if (!select) {
throw new Error(`Select not found for label: ${labelText}`);
}

act(() => {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}

async function screenText() {
await act(async () => {
await Promise.resolve();
});

return containerText();
}

function containerText() {
return containerGlobal().textContent?.replace(/\s+/g, ' ').trim() ?? '';
}

function containerGlobal() {
const node = document.body.lastElementChild;

if (!node) {
throw new Error('Missing rendered container');
}

return node;
}
41 changes: 32 additions & 9 deletions src/components/communities/AddCommunityForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ export function AddCommunityForm({ fullPage, onSuccess }: Props = {}) {
meetup_url: '', website: '', member_count: '', organizer_name: '', organizer_email: ''
});
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{success: boolean; message: string} | null>(null);
const [result, setResult] = useState<{
success: boolean;
message: string;
confirmationId?: string;
} | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -29,13 +33,23 @@ export function AddCommunityForm({ fullPage, onSuccess }: Props = {}) {
});
const data = await res.json();
if (data.success) {
setResult({ success: true, message: 'Community submitted!' });
setTimeout(() => { onSuccess?.() || (fullPage && router.push('/communities')); }, 2000);
setResult({
success: true,
message: 'Community submitted!',
confirmationId: data.submissionId,
});
setTimeout(() => {
if (onSuccess) {
onSuccess();
} else if (fullPage) {
router.push('/communities');
}
}, 2000);
} else {
setResult({ success: false, message: data.error || 'Failed' });
}
} catch (err: any) {
setResult({ success: false, message: err.message });
} catch (err: unknown) {
setResult({ success: false, message: err instanceof Error ? err.message : 'Submission failed' });
} finally {
setSubmitting(false);
}
Expand Down Expand Up @@ -72,7 +86,7 @@ export function AddCommunityForm({ fullPage, onSuccess }: Props = {}) {
<CountrySelect label="Country" required value={formData.country} onChange={(country) => setFormData({...formData, country})} placeholder="Select your country first" />
<div><label className="block text-sm font-medium mb-2">Full Address <span className="text-destructive">*</span></label>
<RFDS.Input required value={formData.address} onChange={(e) => setFormData({...formData, address: e.target.value})} placeholder={getAddressPlaceholder()} className="w-full" />
<p className="text-xs text-muted-foreground mt-1">Include venue name, street, city - we'll use this to place your pin on the map</p>
<p className="text-xs text-muted-foreground mt-1">Include venue name, street, city - we&apos;ll use this to place your pin on the map</p>
</div>
<div><label className="block text-sm font-medium mb-2">City <span className="text-destructive">*</span></label>
<RFDS.Input required value={formData.city} onChange={(e) => setFormData({...formData, city: e.target.value})} placeholder={formData.country === 'United States' ? 'San Francisco' : 'Amsterdam'} className="w-full" />
Expand All @@ -96,9 +110,18 @@ export function AddCommunityForm({ fullPage, onSuccess }: Props = {}) {
<RFDS.Input required type="email" value={formData.organizer_email} onChange={(e) => setFormData({...formData, organizer_email: e.target.value})} className="w-full" />
</div>
</div>
{result && <div className={`p-4 rounded-lg border ${result.success ? 'bg-green-500/10 border-green-500/20' : 'bg-destructive/10 border-destructive/20'}`}>
<p className={`text-sm font-medium ${result.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}>{result.message}</p>
</div>}
{result && (
<div className={`p-4 rounded-lg border ${result.success ? 'bg-success/10 border-success/20' : 'bg-destructive/10 border-destructive/20'}`}>
<p className={`text-sm font-medium ${result.success ? 'text-success' : 'text-destructive'}`}>{result.message}</p>
{result.success && result.confirmationId && (
<p className="mt-2 text-xs text-muted-foreground">
Confirmation ID: <code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">{result.confirmationId}</code>
<br />
Save this ID if you need to follow up on your submission.
</p>
)}
</div>
)}
<RFDS.SemanticButton type="submit" variant="primary" disabled={submitting} className="w-full">
{submitting ? 'Submitting...' : 'Submit Community'}
</RFDS.SemanticButton>
Expand Down
Loading