Skip to content
Open
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
235 changes: 235 additions & 0 deletions app/api/onboarding/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { NextRequest } from "next/server"
import { POST } from "./route"
import { createClient } from "@/lib/supabase/server"
import { createUserGoal, getUserGoal, updateUserGoal } from "@/lib/db"

vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn(),
}))

vi.mock("@/lib/db", () => ({
createUserGoal: vi.fn(),
getUserGoal: vi.fn(),
updateUserGoal: vi.fn(),
}))

describe("POST /api/onboarding", () => {
beforeEach(() => {
vi.clearAllMocks()
})

function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/onboarding", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
}

it("creates a new user goal on first onboarding completion", async () => {
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: "user-1" } } }),
},
} as any)
vi.mocked(getUserGoal).mockResolvedValue(null)
vi.mocked(createUserGoal).mockResolvedValue({
id: "goal-1",
userId: "user-1",
currentStudy: "Computer Science",
wantToStudy: null,
studyDuration: null,
careerGoal: "Software Engineer",
socCode: "15-1252.00",
socTitle: "Software Engineer",
skillGoal: null,
educationLevel: "undergraduate",
studyYear: "Year 3",
topPriority: "finding-internship",
courses: "Algorithms",
isPublic: false,
onboardingCompleted: true,
walletAddress: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any)

const response = await POST(makeRequest({
careerGoal: "Software Engineer",
socCode: "15-1252.00",
socTitle: "Software Engineer",
educationLevel: "undergraduate",
currentStudy: "Computer Science",
studyYear: "Year 3",
topPriority: "finding-internship",
courses: "Algorithms",
}))

expect(response.status).toBe(201)
expect(createUserGoal).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
careerGoal: "Software Engineer",
socCode: "15-1252.00",
socTitle: "Software Engineer",
}),
)
})

it("updates the existing user goal when onboarding is submitted again", async () => {
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: "user-1" } } }),
},
} as any)
vi.mocked(getUserGoal).mockResolvedValue({
id: "goal-1",
userId: "user-1",
currentStudy: null,
wantToStudy: null,
studyDuration: null,
careerGoal: "Old Goal",
socCode: null,
socTitle: null,
skillGoal: null,
educationLevel: null,
studyYear: null,
topPriority: null,
courses: null,
isPublic: false,
onboardingCompleted: true,
walletAddress: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any)
vi.mocked(updateUserGoal).mockResolvedValue({
id: "goal-1",
userId: "user-1",
currentStudy: "Computer Science",
wantToStudy: null,
studyDuration: null,
careerGoal: "Data Engineer",
socCode: "15-1251.00",
socTitle: "Data Engineer",
skillGoal: null,
educationLevel: "graduate",
studyYear: "Year 4",
topPriority: "skill-gaps",
courses: "ML",
isPublic: false,
onboardingCompleted: true,
walletAddress: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any)

const response = await POST(makeRequest({
careerGoal: "Data Engineer",
socCode: "15-1251.00",
socTitle: "Data Engineer",
educationLevel: "graduate",
currentStudy: "Computer Science",
studyYear: "Year 4",
topPriority: "skill-gaps",
courses: "ML",
}))

expect(response.status).toBe(200)
expect(updateUserGoal).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
careerGoal: "Data Engineer",
onboardingCompleted: true,
}),
)
expect(createUserGoal).not.toHaveBeenCalled()
})

it("allows partial onboarding updates without requiring every field", async () => {
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: "user-1" } } }),
},
} as any)
vi.mocked(getUserGoal).mockResolvedValue({
id: "goal-1",
userId: "user-1",
currentStudy: null,
wantToStudy: null,
studyDuration: null,
careerGoal: "Existing Goal",
socCode: null,
socTitle: null,
skillGoal: null,
educationLevel: null,
studyYear: null,
topPriority: null,
courses: null,
isPublic: false,
onboardingCompleted: true,
walletAddress: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any)
vi.mocked(updateUserGoal).mockResolvedValue({
id: "goal-1",
userId: "user-1",
currentStudy: "Computer Engineering",
wantToStudy: null,
studyDuration: null,
careerGoal: "Existing Goal",
socCode: null,
socTitle: null,
skillGoal: null,
educationLevel: null,
studyYear: null,
topPriority: null,
courses: null,
isPublic: false,
onboardingCompleted: true,
walletAddress: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any)

const response = await POST(makeRequest({ currentStudy: "Computer Engineering" }))

expect(response.status).toBe(200)
expect(updateUserGoal).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ currentStudy: "Computer Engineering", onboardingCompleted: true }),
)
})

it("returns a 400 response for invalid onboarding payloads", async () => {
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: "user-1" } } }),
},
} as any)

const response = await POST(makeRequest({
careerGoal: "",
socCode: "not-a-code",
}))
const payload = await response.json()

expect(response.status).toBe(400)
expect(payload.error).toBeTruthy()
})

it("returns a 401 response for unauthorized requests", async () => {
vi.mocked(createClient).mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: null } }),
},
} as any)

const response = await POST(makeRequest({ careerGoal: "Software Engineer" }))
const payload = await response.json()

expect(response.status).toBe(401)
expect(payload.error).toBe("Unauthorized")
})
})
69 changes: 42 additions & 27 deletions app/api/onboarding/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from "next/server"
import { createClient } from "@/lib/supabase/server"
import { createUserGoal } from "@/lib/db"
import { createUserGoal, getUserGoal, updateUserGoal } from "@/lib/db"
import { HTTP, apiError, isUniqueViolation, readJsonBody } from "@/lib/api-errors"
import { onboardingSchema } from "@/lib/validations"

export async function POST(request: NextRequest) {
try {
Expand All @@ -10,35 +12,48 @@ export async function POST(request: NextRequest) {
} = await supabase.auth.getUser()

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
return apiError("Unauthorized", HTTP.UNAUTHORIZED)
}

const body = await request.json()
const {
careerGoal,
socCode,
socTitle,
educationLevel,
currentStudy,
studyYear,
topPriority,
courses,
} = body

const userGoal = await createUserGoal(user.id, {
careerGoal,
socCode: socCode || null,
socTitle: socTitle || null,
educationLevel,
currentStudy,
studyYear,
topPriority,
courses,
})

return NextResponse.json(userGoal)
const parsed = await readJsonBody<unknown>(request)
if (!parsed.ok) return parsed.response

const validation = onboardingSchema.safeParse(parsed.data)
if (!validation.success) {
const issue = validation.error.issues[0]
return apiError(issue?.message || "Invalid onboarding payload.", HTTP.BAD_REQUEST)
}

const payload = validation.data
const existingGoal = await getUserGoal(user.id)

const goalPayload = {
careerGoal: payload.careerGoal,
socCode: payload.socCode ?? null,
socTitle: payload.socTitle ?? null,
educationLevel: payload.educationLevel,
currentStudy: payload.currentStudy,
studyYear: payload.studyYear,
topPriority: payload.topPriority,
courses: payload.courses,
}

const userGoal = existingGoal
? await updateUserGoal(user.id, {
...goalPayload,
onboardingCompleted: true,
})
: await createUserGoal(user.id, goalPayload)

return NextResponse.json(userGoal, { status: existingGoal ? 200 : 201 })
} catch (error) {
if (isUniqueViolation(error)) {
return apiError(
"You've already completed onboarding. Edit your answers on the Goals page.",
HTTP.CONFLICT,
)
}
console.error("Error saving user goals:", error)
return NextResponse.json({ error: "Failed to save goals" }, { status: 500 })
return apiError("Failed to save goals", HTTP.INTERNAL_SERVER_ERROR)
}
}
28 changes: 28 additions & 0 deletions lib/api-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from "next/server"

export const HTTP = {
OK: 200,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
CONFLICT: 409,
INTERNAL_SERVER_ERROR: 500,
} as const

export function apiError(message: string, status: number) {
return NextResponse.json({ error: message }, { status })
}

export async function readJsonBody<T>(request: NextRequest): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
const data = await request.json()
return { ok: true, data: data as T }
} catch {
return { ok: false, response: apiError("Invalid JSON payload.", HTTP.BAD_REQUEST) }
}
}

export function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== "object") return false
const code = (error as { code?: unknown }).code
return code === "23505" || code === "23503" || code === "PGRST116"
}
2 changes: 2 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export async function updateUserGoal(
topPriority?: string
courses?: string
walletAddress?: string | null
onboardingCompleted?: boolean
},
): Promise<UserGoal> {
const supabase = await createClient()
Expand All @@ -314,6 +315,7 @@ export async function updateUserGoal(
if (goalData.topPriority !== undefined) updatePayload.top_priority = goalData.topPriority
if (goalData.courses !== undefined) updatePayload.courses = goalData.courses
if (goalData.walletAddress !== undefined) updatePayload.wallet_address = goalData.walletAddress
if (goalData.onboardingCompleted !== undefined) updatePayload.onboarding_completed = goalData.onboardingCompleted

const { data, error } = await supabase
.from("user_goals")
Expand Down
Loading