diff --git a/src/components/school-dashboard/profile/__tests__/actions.test.ts b/src/components/school-dashboard/profile/__tests__/actions.test.ts index ce0e610ea..775776795 100644 --- a/src/components/school-dashboard/profile/__tests__/actions.test.ts +++ b/src/components/school-dashboard/profile/__tests__/actions.test.ts @@ -1,23 +1,47 @@ +// Copyright (c) 2025-present databayt +// Licensed under SSPL-1.0 -- see LICENSE for details + import { auth } from "@/auth" import { beforeEach, describe, expect, it, vi } from "vitest" import { db } from "@/lib/db" import { getTenantContext } from "@/lib/tenant-context" +import { + getPinnedItems, + getProfileBasicData, + getRecentActivity, + getStaffProfile, + getStudentProfile, + getTeacherProfile, + getUserProfileRole, + logUserActivity, + updateGitHubProfile, + updatePinnedItems, + updateProfile, + updateProfileBio, + uploadProfileAvatar, +} from "../actions" + vi.mock("@/lib/db", () => ({ db: { user: { - update: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), + update: vi.fn(), + }, + student: { findFirst: vi.fn() }, + teacher: { findFirst: vi.fn() }, + guardian: { findFirst: vi.fn() }, + pinnedItem: { + findMany: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + userActivity: { + findMany: vi.fn(), + create: vi.fn(), }, - $transaction: vi.fn((callback) => - callback({ - user: { - update: vi.fn(), - }, - }) - ), }, })) @@ -31,147 +55,502 @@ vi.mock("@/auth", () => ({ vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), + unstable_cache: unknown>(fn: T) => fn, +})) + +vi.mock("@/lib/content-display", () => ({ + getDisplayText: vi.fn(async (text: string) => text), })) -describe("Profile Actions", () => { - const mockSchoolId = "school-123" - const mockUserId = "user-123" +const SCHOOL_ID = "school-1" +const USER_ID = "user-1" + +function asAuthed(role = "TEACHER") { + vi.mocked(auth).mockResolvedValue({ + user: { + id: USER_ID, + schoolId: SCHOOL_ID, + role, + email: `${role.toLowerCase()}@school.edu`, + }, + expires: new Date(Date.now() + 86_400_000).toISOString(), + } as never) + vi.mocked(getTenantContext).mockResolvedValue({ + schoolId: SCHOOL_ID, + subdomain: "demo", + role, + locale: "en", + } as never) +} + +function asUnauthed() { + vi.mocked(auth).mockResolvedValue(null as never) +} + +function asAuthedNoSchool() { + vi.mocked(auth).mockResolvedValue({ + user: { id: USER_ID, role: "DEVELOPER" }, + expires: new Date(Date.now() + 86_400_000).toISOString(), + } as never) + vi.mocked(getTenantContext).mockResolvedValue({ + schoolId: null, + subdomain: null, + role: "DEVELOPER", + locale: "en", + } as never) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("Profile fetch actions", () => { + describe("getStudentProfile", () => { + it("rejects unauthenticated callers with NOT_AUTHENTICATED", async () => { + asUnauthed() + const res = await getStudentProfile() + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("NOT_AUTHENTICATED") + }) + + it("rejects callers without a school context", async () => { + asAuthedNoSchool() + const res = await getStudentProfile() + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("MISSING_SCHOOL") + }) + + it("scopes the query by schoolId and target id", async () => { + asAuthed() + vi.mocked(db.student.findFirst).mockResolvedValue({ + id: "stu-1", + } as never) + await getStudentProfile("stu-1") + const call = vi.mocked(db.student.findFirst).mock.calls[0][0] as { + where: { id: string; schoolId: string } + } + expect(call.where).toMatchObject({ id: "stu-1", schoolId: SCHOOL_ID }) + }) - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getTenantContext).mockResolvedValue({ - schoolId: mockSchoolId, - subdomain: "test-school", + it("falls back to the session user id when none is supplied", async () => { + asAuthed() + vi.mocked(db.student.findFirst).mockResolvedValue({ + id: USER_ID, + } as never) + await getStudentProfile() + const call = vi.mocked(db.student.findFirst).mock.calls[0][0] as { + where: { id: string } + } + expect(call.where.id).toBe(USER_ID) + }) + + it("returns STUDENT_NOT_FOUND when the student is missing", async () => { + asAuthed() + vi.mocked(db.student.findFirst).mockResolvedValue(null) + const res = await getStudentProfile("missing") + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("STUDENT_NOT_FOUND") + }) + }) + + describe("getTeacherProfile", () => { + it("returns TEACHER_NOT_FOUND when the teacher is missing", async () => { + asAuthed() + vi.mocked(db.teacher.findFirst).mockResolvedValue(null) + const res = await getTeacherProfile("missing") + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("TEACHER_NOT_FOUND") + }) + }) + + describe("getStaffProfile", () => { + it("scopes by role to STAFF/ACCOUNTANT/ADMIN/DEVELOPER only", async () => { + asAuthed("ADMIN") + vi.mocked(db.user.findFirst).mockResolvedValue({ id: USER_ID } as never) + await getStaffProfile() + const call = vi.mocked(db.user.findFirst).mock.calls[0][0] as { + where: { role: { in: string[] } } + } + expect(call.where.role.in).toEqual([ + "STAFF", + "ACCOUNTANT", + "ADMIN", + "DEVELOPER", + ]) + }) + }) +}) + +describe("getProfileBasicData", () => { + it("rejects unauthenticated callers", async () => { + asUnauthed() + const res = await getProfileBasicData(USER_ID) + expect(res.success).toBe(false) + }) + + it("returns NOT_FOUND when no user/student/teacher matches in this school", async () => { + asAuthed() + vi.mocked(db.user.findFirst).mockResolvedValue(null) + vi.mocked(db.student.findFirst).mockResolvedValue(null) + vi.mocked(db.teacher.findFirst).mockResolvedValue(null) + const res = await getProfileBasicData("missing") + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("NOT_FOUND") + }) + + it("flattens role-record fields onto the result for downstream UI", async () => { + asAuthed() + vi.mocked(db.user.findFirst).mockResolvedValue({ + id: USER_ID, + username: "alice", + email: "alice@school.edu", + image: null, role: "TEACHER", - locale: "en", - }) - vi.mocked(auth).mockResolvedValue({ - user: { - id: mockUserId, - schoolId: mockSchoolId, - role: "TEACHER", - email: "teacher@school.edu", + bio: "Hello", + website: null, + timezone: null, + pronouns: null, + socialLinks: null, + statusEmoji: null, + statusMessage: null, + createdAt: new Date("2024-01-01"), + teacher: { + id: "tch-1", + firstName: "Alice", + lastName: "Smith", + profilePhotoUrl: "/avatar.png", + employeeId: "EMP-1", + emailAddress: "alice@school.edu", + joiningDate: new Date("2024-01-15"), }, - } as any) + } as never) + + const res = await getProfileBasicData(USER_ID) + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.id).toBe("tch-1") + expect(res.data.firstName).toBe("Alice") + expect(res.data.employeeId).toBe("EMP-1") + expect(res.data.role).toBe("TEACHER") + // session.user.id === USER_ID, profileUserId === USER_ID → OWNER + expect(res.data.viewerPermission).toBe("OWNER") }) - describe("Profile Update", () => { - it("updates profile with schoolId scope", async () => { - const mockUser = { - id: mockUserId, - name: "John Doe", - email: "john@school.edu", - schoolId: mockSchoolId, - } + it("flags the viewer as STAFF when a teacher views another user's profile", async () => { + asAuthed("TEACHER") + vi.mocked(db.user.findFirst).mockResolvedValue({ + id: "other-user", + username: "bob", + email: "bob@school.edu", + image: null, + role: "STUDENT", + bio: null, + website: null, + timezone: null, + pronouns: null, + socialLinks: null, + statusEmoji: null, + statusMessage: null, + createdAt: new Date("2024-01-01"), + student: { + id: "stu-1", + firstName: "Bob", + lastName: "J", + profilePhotoUrl: null, + grNumber: "GR-1", + city: null, + enrollmentDate: null, + email: "bob@school.edu", + application: null, + }, + } as never) + const res = await getProfileBasicData("other-user") + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.viewerPermission).toBe("STAFF") + }) - vi.mocked(db.user.findFirst).mockResolvedValue(mockUser as any) - vi.mocked(db.user.update).mockResolvedValue({ - ...mockUser, - name: "John Updated", - } as any) + it("flags the viewer as ADMIN when a school admin views any other user", async () => { + asAuthed("ADMIN") + vi.mocked(db.user.findFirst).mockResolvedValue({ + id: "other-user", + username: "bob", + email: "bob@school.edu", + image: null, + role: "STUDENT", + bio: null, + website: null, + timezone: null, + pronouns: null, + socialLinks: null, + statusEmoji: null, + statusMessage: null, + createdAt: new Date("2024-01-01"), + } as never) + const res = await getProfileBasicData("other-user") + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.viewerPermission).toBe("ADMIN") + }) - // Verify user belongs to school before update - await db.user.findFirst({ - where: { id: mockUserId, schoolId: mockSchoolId }, - }) + it("falls back to the student table when no User row exists (wizard-created students)", async () => { + asAuthed() + vi.mocked(db.user.findFirst).mockResolvedValue(null) + vi.mocked(db.student.findFirst).mockResolvedValue({ + id: "stu-1", + firstName: "Bob", + lastName: "Jones", + profilePhotoUrl: null, + grNumber: "GR-1", + city: "Riyadh", + enrollmentDate: new Date("2025-09-01"), + email: "bob@school.edu", + createdAt: new Date("2025-09-01"), + } as never) - await db.user.update({ - where: { id: mockUserId }, - data: { name: "John Updated" }, - }) + const res = await getProfileBasicData("stu-1") + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.role).toBe("STUDENT") + expect(res.data.grNumber).toBe("GR-1") + }) +}) - expect(db.user.findFirst).toHaveBeenCalledWith({ - where: { id: mockUserId, schoolId: mockSchoolId }, - }) +describe("Profile mutation actions", () => { + describe("updateProfile", () => { + it("rejects unauthenticated callers", async () => { + asUnauthed() + const res = await updateProfile({ displayName: "Alice", locale: "en" }) + expect(res.success).toBe(false) }) - it("prevents updating other users profiles", async () => { - vi.mocked(db.user.findFirst).mockResolvedValue(null) // Different school + it("returns VALIDATION_ERROR for invalid input", async () => { + asAuthed() + const res = await updateProfile({ + displayName: "", + locale: "en", + } as never) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("VALIDATION_ERROR") + }) - const result = await db.user.findFirst({ - where: { id: "other-user", schoolId: mockSchoolId }, + it("persists the new display name and clears empty avatar to null", async () => { + asAuthed() + vi.mocked(db.user.update).mockResolvedValue({} as never) + const res = await updateProfile({ + displayName: "Alice", + avatarUrl: "", + locale: "en", }) - - expect(result).toBeNull() + expect(res.success).toBe(true) + const call = vi.mocked(db.user.update).mock.calls[0][0] as { + data: { username: string; image: string | null } + } + expect(call.data).toEqual({ username: "Alice", image: null }) }) }) - describe("Profile Fetch", () => { - it("fetches profile scoped to schoolId", async () => { - const mockProfile = { - id: mockUserId, - name: "John Doe", - email: "john@school.edu", - phone: "+1234567890", - schoolId: mockSchoolId, - role: "TEACHER", + describe("updateProfileBio", () => { + it("clears the bio when an empty value is supplied", async () => { + asAuthed() + vi.mocked(db.user.update).mockResolvedValue({} as never) + const res = await updateProfileBio({ bio: "" }) + expect(res.success).toBe(true) + const call = vi.mocked(db.user.update).mock.calls[0][0] as { + data: { bio: string | null } } + expect(call.data.bio).toBeNull() + }) - vi.mocked(db.user.findFirst).mockResolvedValue(mockProfile as any) - - const profile = await db.user.findFirst({ - where: { id: mockUserId, schoolId: mockSchoolId }, - select: { - id: true, - name: true, - email: true, - phone: true, - image: true, - role: true, - }, - }) - - expect(profile).toEqual(mockProfile) - expect(db.user.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: mockUserId, schoolId: mockSchoolId }, - }) - ) + it("rejects bios over 500 characters", async () => { + asAuthed() + const res = await updateProfileBio({ bio: "a".repeat(501) }) + expect(res.success).toBe(false) }) }) - describe("Password Change", () => { - it("validates current password before change", async () => { - const mockUser = { - id: mockUserId, - password: "hashed_password", - schoolId: mockSchoolId, + describe("updateGitHubProfile", () => { + it("persists every supported field", async () => { + asAuthed() + vi.mocked(db.user.update).mockResolvedValue({} as never) + const res = await updateGitHubProfile({ + displayName: "Bob", + bio: "Hello", + website: "https://bob.dev", + timezone: "UTC", + statusEmoji: ":wave:", + statusMessage: "Coding", + pronouns: "he/him", + }) + expect(res.success).toBe(true) + const call = vi.mocked(db.user.update).mock.calls[0][0] as { + data: Record } + expect(call.data.username).toBe("Bob") + expect(call.data.website).toBe("https://bob.dev") + expect(call.data.statusEmoji).toBe(":wave:") + }) + }) - vi.mocked(db.user.findFirst).mockResolvedValue(mockUser as any) + describe("uploadProfileAvatar", () => { + it("rejects unauthenticated callers", async () => { + asUnauthed() + const fd = new FormData() + const res = await uploadProfileAvatar(fd) + expect(res.success).toBe(false) + }) - const user = await db.user.findFirst({ - where: { id: mockUserId, schoolId: mockSchoolId }, - select: { password: true }, - }) + it("rejects requests with no file", async () => { + asAuthed() + const res = await uploadProfileAvatar(new FormData()) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("VALIDATION_ERROR") + }) - expect(user?.password).toBeDefined() + it("rejects unsupported MIME types", async () => { + asAuthed() + const fd = new FormData() + fd.append("avatar", new File(["x"], "a.bmp", { type: "image/bmp" })) + const res = await uploadProfileAvatar(fd) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("INVALID_FILE_TYPE") + }) + + it("rejects oversized files (> 5 MB)", async () => { + asAuthed() + const big = new Uint8Array(5 * 1024 * 1024 + 1) + const fd = new FormData() + fd.append("avatar", new File([big], "a.png", { type: "image/png" })) + const res = await uploadProfileAvatar(fd) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("UPLOAD_FAILED") }) }) +}) - describe("Avatar Upload", () => { - it("updates avatar URL with schoolId verification", async () => { - vi.mocked(db.user.findFirst).mockResolvedValue({ - id: mockUserId, - schoolId: mockSchoolId, - } as any) - vi.mocked(db.user.update).mockResolvedValue({ - id: mockUserId, - image: "https://storage.example.com/avatars/user-123.jpg", - } as any) +describe("Pinned items", () => { + it("scopes by schoolId and userId", async () => { + asAuthed() + vi.mocked(db.pinnedItem.findMany).mockResolvedValue([]) + await getPinnedItems() + const call = vi.mocked(db.pinnedItem.findMany).mock.calls[0][0] as { + where: { schoolId: string; userId: string } + } + expect(call.where.schoolId).toBe(SCHOOL_ID) + expect(call.where.userId).toBe(USER_ID) + }) - // First verify user belongs to school - await db.user.findFirst({ - where: { id: mockUserId, schoolId: mockSchoolId }, - }) + it("hides private pinned items when viewing a different user", async () => { + asAuthed() + vi.mocked(db.pinnedItem.findMany).mockResolvedValue([]) + await getPinnedItems("other-user") + const call = vi.mocked(db.pinnedItem.findMany).mock.calls[0][0] as { + where: { isPublic?: boolean } + } + expect(call.where.isPublic).toBe(true) + }) - // Then update - const result = await db.user.update({ - where: { id: mockUserId }, - data: { image: "https://storage.example.com/avatars/user-123.jpg" }, - }) + it("rejects more than six pinned items", async () => { + asAuthed() + const items = Array.from({ length: 7 }, (_, i) => ({ + itemType: "PROJECT" as const, + itemId: `id-${i}`, + title: `Title ${i}`, + isPublic: true, + })) + const res = await updatePinnedItems(items) + expect(res.success).toBe(false) + if (!res.success) { + expect(res.error).toBe("VALIDATION_ERROR") + expect(res.details).toBe("MAX_PINNED_EXCEEDED") + } + }) + + it("replaces existing items in a delete-then-create transaction-like flow", async () => { + asAuthed() + vi.mocked(db.pinnedItem.deleteMany).mockResolvedValue({ count: 0 } as never) + vi.mocked(db.pinnedItem.createMany).mockResolvedValue({ count: 2 } as never) + const res = await updatePinnedItems([ + { itemType: "PROJECT", itemId: "p1", title: "P1", isPublic: true }, + { itemType: "COURSE", itemId: "c1", title: "C1", isPublic: false }, + ]) + expect(res.success).toBe(true) + expect(db.pinnedItem.deleteMany).toHaveBeenCalled() + expect(db.pinnedItem.createMany).toHaveBeenCalled() + }) +}) - expect(result.image).toContain("avatars") +describe("Recent activity", () => { + it("scopes by schoolId + userId and limits results", async () => { + asAuthed() + vi.mocked(db.userActivity.findMany).mockResolvedValue([]) + await getRecentActivity(undefined, 5) + const call = vi.mocked(db.userActivity.findMany).mock.calls[0][0] as { + where: { schoolId: string; userId: string } + take: number + } + expect(call.where.schoolId).toBe(SCHOOL_ID) + expect(call.where.userId).toBe(USER_ID) + expect(call.take).toBe(5) + }) + + it("logUserActivity persists with the session schoolId + userId", async () => { + asAuthed() + vi.mocked(db.userActivity.create).mockResolvedValue({} as never) + await logUserActivity({ + activityType: "PROFILE_UPDATED", + title: "Updated bio", }) + const call = vi.mocked(db.userActivity.create).mock.calls[0][0] as { + data: { schoolId: string; userId: string; activityType: string } + } + expect(call.data.schoolId).toBe(SCHOOL_ID) + expect(call.data.userId).toBe(USER_ID) + expect(call.data.activityType).toBe("PROFILE_UPDATED") + }) +}) + +describe("getUserProfileRole", () => { + it("maps STUDENT to 'student'", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + expect(await getUserProfileRole("u1")).toBe("student") + }) + + it("maps TEACHER to 'teacher'", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "TEACHER", + } as never) + expect(await getUserProfileRole("u1")).toBe("teacher") + }) + + it("maps GUARDIAN to 'parent'", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "GUARDIAN", + } as never) + expect(await getUserProfileRole("u1")).toBe("parent") + }) + + it("maps STAFF/ADMIN/ACCOUNTANT to 'staff'", async () => { + asAuthed() + for (const role of ["STAFF", "ADMIN", "ACCOUNTANT"] as const) { + vi.mocked(db.user.findUnique).mockResolvedValue({ role } as never) + expect(await getUserProfileRole("u1")).toBe("staff") + } + }) + + it("returns null for unknown roles", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "USER" } as never) + expect(await getUserProfileRole("u1")).toBeNull() + }) + + it("returns null when no session and no userId is given", async () => { + asUnauthed() + expect(await getUserProfileRole()).toBeNull() }) }) diff --git a/src/components/school-dashboard/profile/__tests__/contribution.test.ts b/src/components/school-dashboard/profile/__tests__/contribution.test.ts new file mode 100644 index 000000000..08285d598 --- /dev/null +++ b/src/components/school-dashboard/profile/__tests__/contribution.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2025-present databayt +// Licensed under SSPL-1.0 -- see LICENSE for details + +import { auth } from "@/auth" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { db } from "@/lib/db" +import { getTenantContext } from "@/lib/tenant-context" + +import { getContributionData } from "../actions" + +vi.mock("@/lib/db", () => ({ + db: { + user: { findUnique: vi.fn() }, + student: { findFirst: vi.fn() }, + teacher: { findFirst: vi.fn() }, + attendance: { findMany: vi.fn() }, + assignmentSubmission: { findMany: vi.fn() }, + result: { findMany: vi.fn() }, + borrowRecord: { findMany: vi.fn() }, + payment: { findMany: vi.fn() }, + message: { findMany: vi.fn() }, + timesheetEntry: { findMany: vi.fn() }, + expense: { findMany: vi.fn() }, + }, +})) + +vi.mock("@/lib/tenant-context", () => ({ getTenantContext: vi.fn() })) +vi.mock("@/auth", () => ({ auth: vi.fn() })) +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), + unstable_cache: unknown>(fn: T) => fn, +})) + +const SCHOOL_ID = "school-1" +const USER_ID = "user-1" + +function asAuthed(role = "STUDENT") { + vi.mocked(auth).mockResolvedValue({ + user: { id: USER_ID, schoolId: SCHOOL_ID, role, email: "u@school.edu" }, + expires: new Date(Date.now() + 86_400_000).toISOString(), + } as never) + vi.mocked(getTenantContext).mockResolvedValue({ + schoolId: SCHOOL_ID, + subdomain: "demo", + role, + locale: "en", + } as never) +} + +beforeEach(() => { + vi.clearAllMocks() + for (const m of [ + db.attendance, + db.assignmentSubmission, + db.result, + db.borrowRecord, + db.payment, + db.message, + db.timesheetEntry, + db.expense, + ]) { + vi.mocked(m.findMany).mockResolvedValue([] as never) + } +}) + +describe("getContributionData", () => { + it("rejects unauthenticated callers", async () => { + vi.mocked(auth).mockResolvedValue(null as never) + const res = await getContributionData() + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("NOT_AUTHENTICATED") + }) + + it("rejects callers without school context", async () => { + vi.mocked(auth).mockResolvedValue({ + user: { id: USER_ID, role: "DEVELOPER" }, + expires: new Date(Date.now() + 86_400_000).toISOString(), + } as never) + vi.mocked(getTenantContext).mockResolvedValue({ + schoolId: null, + subdomain: null, + role: "DEVELOPER", + locale: "en", + } as never) + const res = await getContributionData() + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("MISSING_SCHOOL") + }) + + it("rejects years before 2020", async () => { + asAuthed() + const res = await getContributionData({ year: 2019 }) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("VALIDATION_ERROR") + }) + + it("rejects years too far in the future", async () => { + asAuthed() + const res = await getContributionData({ + year: new Date().getFullYear() + 5, + }) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("VALIDATION_ERROR") + }) + + it("returns NOT_FOUND when the user does not exist", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue(null) + const res = await getContributionData({ userId: "missing" }) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("NOT_FOUND") + }) + + it("returns UNKNOWN when the role is not mappable to a profile role", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ role: "USER" } as never) + const res = await getContributionData({ userId: USER_ID }) + expect(res.success).toBe(false) + if (!res.success) expect(res.error).toBe("UNKNOWN") + }) + + it("returns a 365-or-366 day contribution map for the requested year", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + vi.mocked(db.student.findFirst).mockResolvedValue({ id: "stu-1" } as never) + + const res = await getContributionData({ userId: USER_ID, year: 2025 }) + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.year).toBe(2025) + expect(res.data.role).toBe("student") + // 2025 is not a leap year — exactly 365 days + expect(res.data.contributions).toHaveLength(365) + expect(res.data.contributions[0].date).toBe("2025-01-01") + expect(res.data.contributions[364].date).toBe("2025-12-31") + }) + + it("includes 366 days for leap years", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + vi.mocked(db.student.findFirst).mockResolvedValue({ id: "stu-1" } as never) + + const res = await getContributionData({ userId: USER_ID, year: 2024 }) + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.contributions).toHaveLength(366) + }) + + it("aggregates student attendance/submissions/results into the map", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + vi.mocked(db.student.findFirst).mockResolvedValue({ id: "stu-1" } as never) + vi.mocked(db.attendance.findMany).mockResolvedValue([ + { date: new Date("2025-01-15") }, + { date: new Date("2025-01-15") }, + ] as never) + vi.mocked(db.assignmentSubmission.findMany).mockResolvedValue([ + { submittedAt: new Date("2025-01-15") }, + ] as never) + vi.mocked(db.result.findMany).mockResolvedValue([ + { gradedAt: new Date("2025-01-20") }, + ] as never) + + const res = await getContributionData({ userId: USER_ID, year: 2025 }) + expect(res.success).toBe(true) + if (!res.success) return + + const day15 = res.data.contributions.find((d) => d.date === "2025-01-15") + expect(day15).toBeDefined() + expect(day15!.count).toBe(3) // 2 attendance + 1 submission + expect(day15!.activities.find((a) => a.type === "attendance")?.count).toBe( + 2 + ) + expect( + day15!.activities.find((a) => a.type === "assignment_submitted")?.count + ).toBe(1) + + expect(res.data.totalActivities).toBe(4) + }) + + it("computes summary streaks correctly", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + vi.mocked(db.student.findFirst).mockResolvedValue({ id: "stu-1" } as never) + // Three consecutive days with activity + vi.mocked(db.attendance.findMany).mockResolvedValue([ + { date: new Date("2025-06-01") }, + { date: new Date("2025-06-02") }, + { date: new Date("2025-06-03") }, + ] as never) + + const res = await getContributionData({ userId: USER_ID, year: 2025 }) + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.summary.activeDays).toBe(3) + expect(res.data.summary.longestStreak).toBe(3) + expect(res.data.summary.peakDay).toBeDefined() + expect(res.data.summary.peakDay!.count).toBe(1) + }) + + it("returns an empty contribution map when the role record is missing", async () => { + asAuthed("STUDENT") + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + vi.mocked(db.student.findFirst).mockResolvedValue(null) + + const res = await getContributionData({ userId: USER_ID, year: 2025 }) + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.totalActivities).toBe(0) + expect(res.data.summary.activeDays).toBe(0) + }) + + it("uses the current year when none is supplied", async () => { + asAuthed() + vi.mocked(db.user.findUnique).mockResolvedValue({ + role: "STUDENT", + } as never) + vi.mocked(db.student.findFirst).mockResolvedValue({ id: "stu-1" } as never) + const res = await getContributionData({ userId: USER_ID }) + expect(res.success).toBe(true) + if (!res.success) return + expect(res.data.year).toBe(new Date().getFullYear()) + }) +}) diff --git a/src/components/school-dashboard/profile/__tests__/validation.test.ts b/src/components/school-dashboard/profile/__tests__/validation.test.ts new file mode 100644 index 000000000..4e52cb5c7 --- /dev/null +++ b/src/components/school-dashboard/profile/__tests__/validation.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2025-present databayt +// Licensed under SSPL-1.0 -- see LICENSE for details + +import { describe, expect, it } from "vitest" + +import { + pinnedItemSchema, + updateBioSchema, + updateGitHubProfileSchema, + updateProfileSchema, + updateSettingsSchema, +} from "../validation" + +describe("Profile Validation Schemas", () => { + describe("updateProfileSchema", () => { + it("accepts a valid display name", () => { + const result = updateProfileSchema.safeParse({ displayName: "Alice" }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.locale).toBe("ar") + }) + + it("rejects an empty display name", () => { + const result = updateProfileSchema.safeParse({ displayName: "" }) + expect(result.success).toBe(false) + }) + + it("accepts an avatar URL", () => { + const result = updateProfileSchema.safeParse({ + displayName: "Alice", + avatarUrl: "https://cdn.example.com/a.jpg", + }) + expect(result.success).toBe(true) + }) + + it("treats empty string avatar as a clear-image signal", () => { + const result = updateProfileSchema.safeParse({ + displayName: "Alice", + avatarUrl: "", + }) + expect(result.success).toBe(true) + }) + + it("rejects a malformed avatar URL", () => { + const result = updateProfileSchema.safeParse({ + displayName: "Alice", + avatarUrl: "not-a-url", + }) + expect(result.success).toBe(false) + }) + + it("locks locale to ar or en", () => { + const ok = updateProfileSchema.safeParse({ + displayName: "Alice", + locale: "en", + }) + const bad = updateProfileSchema.safeParse({ + displayName: "Alice", + locale: "fr", + }) + expect(ok.success).toBe(true) + expect(bad.success).toBe(false) + }) + }) + + describe("updateBioSchema", () => { + it("accepts an empty bio (clear)", () => { + expect(updateBioSchema.safeParse({}).success).toBe(true) + }) + + it("accepts a 500-character bio", () => { + const bio = "a".repeat(500) + expect(updateBioSchema.safeParse({ bio }).success).toBe(true) + }) + + it("rejects a bio over 500 characters", () => { + const bio = "a".repeat(501) + expect(updateBioSchema.safeParse({ bio }).success).toBe(false) + }) + }) + + describe("updateSettingsSchema", () => { + it("accepts an empty payload (no changes)", () => { + expect(updateSettingsSchema.safeParse({}).success).toBe(true) + }) + + it("accepts all known theme values", () => { + for (const theme of ["light", "dark", "system"] as const) { + expect(updateSettingsSchema.safeParse({ theme }).success).toBe(true) + } + }) + + it("rejects unknown theme values", () => { + expect(updateSettingsSchema.safeParse({ theme: "neon" }).success).toBe( + false + ) + }) + + it("accepts boolean notification preferences", () => { + const result = updateSettingsSchema.safeParse({ + emailNotifications: true, + pushNotifications: false, + allowMessages: true, + }) + expect(result.success).toBe(true) + }) + }) + + describe("updateGitHubProfileSchema", () => { + it("accepts a fully populated profile", () => { + const result = updateGitHubProfileSchema.safeParse({ + displayName: "Bob", + bio: "Software engineer", + website: "https://bob.dev", + timezone: "UTC", + statusEmoji: ":wave:", + statusMessage: "Coding", + pronouns: "he/him", + socialLinks: { + github: "https://github.com/bob", + twitter: "https://twitter.com/bob", + linkedin: "https://linkedin.com/in/bob", + }, + }) + expect(result.success).toBe(true) + }) + + it("rejects display names over 100 characters", () => { + const result = updateGitHubProfileSchema.safeParse({ + displayName: "a".repeat(101), + }) + expect(result.success).toBe(false) + }) + + it("rejects status messages over 100 characters", () => { + const result = updateGitHubProfileSchema.safeParse({ + statusMessage: "a".repeat(101), + }) + expect(result.success).toBe(false) + }) + + it("accepts empty social link strings (clear-link)", () => { + const result = updateGitHubProfileSchema.safeParse({ + socialLinks: { github: "", twitter: "", linkedin: "" }, + }) + expect(result.success).toBe(true) + }) + + it("rejects malformed social link URLs", () => { + const result = updateGitHubProfileSchema.safeParse({ + socialLinks: { github: "not-a-url" }, + }) + expect(result.success).toBe(false) + }) + }) + + describe("pinnedItemSchema", () => { + it("accepts every supported item type", () => { + const types = [ + "COURSE", + "SUBJECT", + "PROJECT", + "ACHIEVEMENT", + "CERTIFICATE", + "CLASS", + "CHILD", + "DEPARTMENT", + "PUBLICATION", + "TASK", + ] as const + for (const itemType of types) { + const result = pinnedItemSchema.safeParse({ + itemType, + itemId: "id", + title: "title", + }) + expect(result.success).toBe(true) + } + }) + + it("rejects unsupported item types", () => { + const result = pinnedItemSchema.safeParse({ + itemType: "RECIPE", + itemId: "id", + title: "title", + }) + expect(result.success).toBe(false) + }) + + it("defaults isPublic to true when omitted", () => { + const result = pinnedItemSchema.safeParse({ + itemType: "PROJECT", + itemId: "id", + title: "title", + }) + if (!result.success) throw new Error("Schema should accept this input") + expect(result.data.isPublic).toBe(true) + }) + + it("requires itemId and title", () => { + const noId = pinnedItemSchema.safeParse({ + itemType: "PROJECT", + title: "title", + }) + const noTitle = pinnedItemSchema.safeParse({ + itemType: "PROJECT", + itemId: "id", + }) + expect(noId.success).toBe(false) + expect(noTitle.success).toBe(false) + }) + }) +}) diff --git a/src/components/school-dashboard/profile/actions.ts b/src/components/school-dashboard/profile/actions.ts index 37df81ef4..00de1b3de 100644 --- a/src/components/school-dashboard/profile/actions.ts +++ b/src/components/school-dashboard/profile/actions.ts @@ -10,6 +10,8 @@ import { getDisplayText } from "@/lib/content-display" import { db } from "@/lib/db" import { getTenantContext } from "@/lib/tenant-context" +import { getPermissionLevel } from "./detail/permissions" +import type { PermissionLevel, ProfileContext } from "./detail/types" import type { ActivityType, ContributionDataPoint, @@ -28,6 +30,31 @@ import { updateSettingsSchema, } from "./validation" +/** + * Compute the viewer's permission level for a given profile. + * Used by getProfileBasicData so the UI can mask sensitive fields + * (emailAddress, employeeId, joiningDate) for non-owners and non-admins. + * + * The strict admin user-detail-page filter lives in detail/actions.ts. + */ +function computeViewerPermission(args: { + viewerId: string | null + viewerRole: string | null | undefined + viewerSchoolId: string | null + profileUserId: string + profileSchoolId: string | null +}): PermissionLevel { + const ctx: ProfileContext = { + viewerId: args.viewerId, + viewerRole: (args.viewerRole as ProfileContext["viewerRole"]) ?? null, + viewerSchoolId: args.viewerSchoolId, + profileUserId: args.profileUserId, + profileSchoolId: args.profileSchoolId, + profileType: "USER", + } + return getPermissionLevel(ctx) +} + // ============================================================================ // Profile Fetching Actions // ============================================================================ @@ -326,22 +353,12 @@ export async function updateProfileSettings( return actionError(ACTION_ERRORS.NOT_AUTHENTICATED) } - const parsed = updateSettingsSchema.parse(input) + updateSettingsSchema.parse(input) - // Update user settings - // Note: This assumes you have a UserSettings table or similar - // If not, you may need to store settings in JSON field on User table - await db.user.update({ - where: { id: session.user.id }, - data: { - // Store settings as JSON or in separate table - // This is a placeholder - adjust based on your schema - }, - }) - - revalidatePath("/profile") - revalidatePath("/profile/settings") - return { success: true as const } + // The User schema does not yet have settings columns (theme, notification + // prefs, allowMessages). Returning NOT_IMPLEMENTED so the UI can render a + // proper translated message instead of a misleading success toast. + return actionError(ACTION_ERRORS.NOT_IMPLEMENTED) } catch (error) { console.error("Error updating settings:", error) if (error instanceof z.ZodError) { @@ -351,6 +368,14 @@ export async function updateProfileSettings( } } +const AVATAR_MAX_BYTES = 5 * 1024 * 1024 +const AVATAR_ALLOWED_MIME = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]) + export async function uploadProfileAvatar(formData: FormData) { try { const session = await auth() @@ -358,31 +383,42 @@ export async function uploadProfileAvatar(formData: FormData) { return actionError(ACTION_ERRORS.NOT_AUTHENTICATED) } - // This is a placeholder for file upload logic - // You would typically: - // 1. Extract the file from formData - // 2. Upload to cloud storage (S3, Cloudinary, etc.) - // 3. Get the URL - // 4. Update user's image field + const raw = formData.get("avatar") + if (!(raw instanceof File) || raw.size === 0) { + return actionError(ACTION_ERRORS.VALIDATION_ERROR) + } + + if (!AVATAR_ALLOWED_MIME.has(raw.type)) { + return actionError(ACTION_ERRORS.INVALID_FILE_TYPE) + } - const file = formData.get("avatar") as File - if (!file) { - return actionError(ACTION_ERRORS.UNKNOWN) + if (raw.size > AVATAR_MAX_BYTES) { + return actionError(ACTION_ERRORS.UPLOAD_FAILED, "FILE_TOO_LARGE") } - // Placeholder for upload logic - // const uploadedUrl = await uploadToCloudStorage(file) + // Hand off to the shared file/upload pipeline (auth + tenant + S3 + DB). + // Imported lazily so that test mocks and tree-shaking are unaffected. + const { uploadFile } = await import("@/components/file/upload/actions") - // await db.user.update({ - // where: { id: session.user.id }, - // data: { image: uploadedUrl }, - // }) + const fd = new FormData() + fd.append("file", raw) + const result = await uploadFile(fd, { + category: "image", + type: "avatar", + access: "public", + }) - revalidatePath("/profile") - return { - success: true as const, - message: "Avatar upload not yet implemented", + if (!result.success || !result.url) { + return actionError(ACTION_ERRORS.UPLOAD_FAILED) } + + await db.user.update({ + where: { id: session.user.id }, + data: { image: result.url }, + }) + + revalidatePath("/profile") + return { success: true as const, data: { url: result.url } } } catch (error) { console.error("Error uploading avatar:", error) return actionError(ACTION_ERRORS.UPLOAD_FAILED) @@ -509,6 +545,15 @@ export async function getProfileBasicData(userId: string, lang?: string) { city: student.city, enrollmentDate: student.enrollmentDate?.toISOString(), role: "STUDENT", + viewerPermission: computeViewerPermission({ + viewerId: session.user.id, + viewerRole: session.user.role, + viewerSchoolId: session.user.schoolId ?? null, + // Wizard-created students have no User row, so the viewer can + // never be the "owner" — treat them as a peer/admin viewer. + profileUserId: student.id, + profileSchoolId: schoolId, + }), } if (lang && lang !== "ar" && schoolId && data.firstName) { @@ -550,6 +595,13 @@ export async function getProfileBasicData(userId: string, lang?: string) { employeeId: teacher.employeeId, joiningDate: teacher.joiningDate?.toISOString(), role: "TEACHER", + viewerPermission: computeViewerPermission({ + viewerId: session.user.id, + viewerRole: session.user.role, + viewerSchoolId: session.user.schoolId ?? null, + profileUserId: teacher.id, + profileSchoolId: schoolId, + }), } if (lang && lang !== "ar" && schoolId && data.firstName) { @@ -606,6 +658,18 @@ export async function getProfileBasicData(userId: string, lang?: string) { statusEmoji: user.statusEmoji, statusMessage: user.statusMessage, role: user.role, + // Viewer permission level — lets the UI choose whether to show + // contact details (emailAddress, employeeId, etc.) or hide them. + // See detail/permissions.ts for the strict admin-detail-page filter. + viewerPermission: computeViewerPermission({ + viewerId: session.user.id, + viewerRole: session.user.role, + viewerSchoolId: session.user.schoolId ?? null, + profileUserId: user.id, + // user was found via `where: { id, schoolId }` so schoolId is the + // tenant we already resolved. + profileSchoolId: schoolId, + }), } // Translate name and bio if viewing in a different language @@ -733,10 +797,7 @@ export async function updatePinnedItems( // Validate max 6 pinned items if (items.length > 6) { - return { - success: false as const, - error: "Maximum 6 pinned items allowed", - } + return actionError(ACTION_ERRORS.VALIDATION_ERROR, "MAX_PINNED_EXCEEDED") } // Parse all items @@ -796,9 +857,12 @@ function mapUserRoleToProfileRole(role: string): ProfileRole | null { } } +// Use UTC throughout the contribution map so the boundaries are stable +// regardless of the server's local timezone (Vercel runs UTC, but devs +// in MENA / Asia were getting Dec 31 → Jan 1 drift). function getYearDateRange(year: number): { startDate: Date; endDate: Date } { - const startDate = new Date(year, 0, 1) - const endDate = new Date(year, 11, 31) + const startDate = new Date(Date.UTC(year, 0, 1)) + const endDate = new Date(Date.UTC(year, 11, 31, 23, 59, 59, 999)) return { startDate, endDate } } @@ -821,7 +885,7 @@ function initializeContributionMap( level: 0, activities: [], }) - current.setDate(current.getDate() + 1) + current.setUTCDate(current.getUTCDate() + 1) } return map diff --git a/src/components/school-dashboard/profile/client.tsx b/src/components/school-dashboard/profile/client.tsx index c802a680b..99eb2a828 100644 --- a/src/components/school-dashboard/profile/client.tsx +++ b/src/components/school-dashboard/profile/client.tsx @@ -7,24 +7,11 @@ import { useSidebar } from "@/components/ui/sidebar" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { OcticonBook, - OcticonClock, OcticonClose, - OcticonExpand, - OcticonFlame, - OcticonGitFork, - OcticonGitMerge, - OcticonGrabber, OcticonIssueOpened, - OcticonOrganization, OcticonPackage, - OcticonPeople, - OcticonPullRequest, OcticonRepo, - OcticonRepoPush, - OcticonSmiley, OcticonStar, - OcticonStarFilled, - OcticonStarLarge, OcticonTable, OcticonTriangleDown, } from "@/components/atom/icons" @@ -314,67 +301,6 @@ export default function ProfileContent({ ))} - - {/* Icon Reference Grid */} -
-

- {p?.overview?.octiconIcons ?? "Octicon Icons"} -

-
- {[ - { icon: , name: "Repo" }, - { icon: , name: "Book" }, - { icon: , name: "Table" }, - { - icon: , - name: "Package", - }, - { icon: , name: "Star" }, - { - icon: , - name: "StarFill", - }, - { - icon: , - name: "StarLg", - }, - { icon: , name: "Fork" }, - { - icon: , - name: "Org", - }, - { icon: , name: "Clock" }, - { icon: , name: "People" }, - { icon: , name: "Smiley" }, - { icon: , name: "Push" }, - { icon: , name: "Expand" }, - { - icon: , - name: "Grabber", - }, - { icon: , name: "Close" }, - { icon: , name: "PR" }, - { icon: , name: "Flame" }, - { icon: , name: "Merge" }, - { - icon: , - name: "Issue", - }, - { - icon: , - name: "TriDown", - }, - ].map((item) => ( -
- {item.icon} - {item.name} -
- ))} -
-
{/* Other Tabs - Show Role Dashboard */} diff --git a/src/components/school-dashboard/profile/detail/__tests__/permissions.test.ts b/src/components/school-dashboard/profile/detail/__tests__/permissions.test.ts new file mode 100644 index 000000000..527ec0c6c --- /dev/null +++ b/src/components/school-dashboard/profile/detail/__tests__/permissions.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2025-present databayt +// Licensed under SSPL-1.0 -- see LICENSE for details + +import { describe, expect, it } from "vitest" + +import { + canEditSection, + canViewField, + filterProfileData, + getPermissionLevel, +} from "../permissions" +import type { ProfileContext, ProfileData } from "../types" + +const baseContext: Omit = { + viewerSchoolId: "school-1", + profileUserId: "user-target", + profileSchoolId: "school-1", + profileType: "STUDENT", +} + +const baseProfile: ProfileData = { + id: "user-target", + username: "alice", + email: "alice@school.edu", + emailVerified: null, + image: null, + role: "STUDENT" as never, + schoolId: "school-1", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-02"), + profileType: "STUDENT", +} + +describe("getPermissionLevel", () => { + it("returns PUBLIC when viewer is unauthenticated", () => { + const level = getPermissionLevel({ + ...baseContext, + viewerId: null, + viewerRole: null, + }) + expect(level).toBe("PUBLIC") + }) + + it("returns OWNER when viewer is the profile user", () => { + const level = getPermissionLevel({ + ...baseContext, + viewerId: "user-target", + viewerRole: "STUDENT" as never, + }) + expect(level).toBe("OWNER") + }) + + it("returns ADMIN for school admin viewing a peer", () => { + const level = getPermissionLevel({ + ...baseContext, + viewerId: "admin-1", + viewerRole: "ADMIN" as never, + }) + expect(level).toBe("ADMIN") + }) + + it("returns ADMIN for platform DEVELOPER", () => { + const level = getPermissionLevel({ + ...baseContext, + viewerId: "dev-1", + viewerRole: "DEVELOPER" as never, + }) + expect(level).toBe("ADMIN") + }) + + it("returns STAFF for teachers viewing other users", () => { + for (const role of ["TEACHER", "STAFF", "ACCOUNTANT"] as const) { + const level = getPermissionLevel({ + ...baseContext, + viewerId: "staff-1", + viewerRole: role as never, + }) + expect(level).toBe("STAFF") + } + }) + + it("returns RELATED for students viewing peers in the same school", () => { + const level = getPermissionLevel({ + ...baseContext, + viewerId: "student-2", + viewerRole: "STUDENT" as never, + }) + expect(level).toBe("RELATED") + }) + + it("returns PUBLIC for students from a different school", () => { + const level = getPermissionLevel({ + ...baseContext, + viewerId: "student-2", + viewerRole: "STUDENT" as never, + viewerSchoolId: "school-other", + }) + expect(level).toBe("PUBLIC") + }) +}) + +describe("filterProfileData", () => { + it("returns the full profile to OWNER", () => { + const result = filterProfileData(baseProfile, "OWNER") + expect(result.canViewFullProfile).toBe(true) + expect(result.email).toBe("alice@school.edu") + }) + + it("returns the full profile to ADMIN", () => { + const result = filterProfileData(baseProfile, "ADMIN") + expect(result.canViewFullProfile).toBe(true) + expect(result.email).toBe("alice@school.edu") + }) + + it("hides email from RELATED viewers", () => { + const result = filterProfileData(baseProfile, "RELATED") + expect(result.canViewFullProfile).toBe(false) + expect(result.email).toBeUndefined() + }) + + it("returns minimal data for PUBLIC viewers", () => { + const result = filterProfileData(baseProfile, "PUBLIC") + expect(result.canViewFullProfile).toBe(false) + expect(result.email).toBeUndefined() + expect(result.username).toBe("alice") + }) + + it("preserves identifying fields for every level", () => { + for (const level of [ + "OWNER", + "ADMIN", + "STAFF", + "RELATED", + "PUBLIC", + ] as const) { + const result = filterProfileData(baseProfile, level) + expect(result.id).toBe("user-target") + expect(result.role).toBe(baseProfile.role) + } + }) +}) + +describe("canViewField", () => { + it("allows OWNER to view every field", () => { + expect(canViewField("medicalConditions", "OWNER")).toBe(true) + expect(canViewField("dateOfBirth", "OWNER")).toBe(true) + }) + + it("allows ADMIN to view every field", () => { + expect(canViewField("medicalConditions", "ADMIN")).toBe(true) + expect(canViewField("anyField", "ADMIN")).toBe(true) + }) + + it("limits STAFF to known staff fields", () => { + expect(canViewField("medicalConditions", "STAFF")).toBe(true) + expect(canViewField("dateOfBirth", "STAFF")).toBe(true) + expect(canViewField("password", "STAFF")).toBe(false) + }) + + it("hides sensitive fields from RELATED viewers", () => { + expect(canViewField("medicalConditions", "RELATED")).toBe(false) + expect(canViewField("email", "RELATED")).toBe(true) + }) + + it("limits PUBLIC viewers to identifying fields only", () => { + expect(canViewField("username", "PUBLIC")).toBe(true) + expect(canViewField("email", "PUBLIC")).toBe(false) + expect(canViewField("medicalConditions", "PUBLIC")).toBe(false) + }) +}) + +describe("canEditSection", () => { + it("lets a teacher self-edit contact, qualifications, experience", () => { + expect(canEditSection(true, false, "teacher", "contact")).toBe(true) + expect(canEditSection(true, false, "teacher", "qualifications")).toBe(true) + expect(canEditSection(true, false, "teacher", "experience")).toBe(true) + }) + + it("blocks teachers from self-editing employment", () => { + expect(canEditSection(true, false, "teacher", "employment")).toBe(false) + }) + + it("lets a student self-edit contact only", () => { + expect(canEditSection(true, false, "student", "contact")).toBe(true) + expect(canEditSection(true, false, "student", "personal")).toBe(false) + expect(canEditSection(true, false, "student", "health")).toBe(false) + }) + + it("lets an admin edit every documented section", () => { + const teacherSections = [ + "information", + "contact", + "employment", + "qualifications", + "experience", + "expertise", + ] + for (const s of teacherSections) { + expect(canEditSection(false, true, "teacher", s)).toBe(true) + } + const studentSections = [ + "personal", + "enrollment", + "contact", + "location", + "health", + "previous-education", + ] + for (const s of studentSections) { + expect(canEditSection(false, true, "student", s)).toBe(true) + } + }) + + it("denies edits when neither owner nor admin", () => { + expect(canEditSection(false, false, "teacher", "contact")).toBe(false) + expect(canEditSection(false, false, "student", "contact")).toBe(false) + }) + + it("denies edits for unknown roles", () => { + expect(canEditSection(true, true, "guardian", "contact")).toBe(false) + }) +}) diff --git a/src/components/school-dashboard/profile/detail/actions.ts b/src/components/school-dashboard/profile/detail/actions.ts index 4bcf4a23f..693668011 100644 --- a/src/components/school-dashboard/profile/detail/actions.ts +++ b/src/components/school-dashboard/profile/detail/actions.ts @@ -1,5 +1,18 @@ "use server" +/** + * Strict-permission profile fetcher — foundation for a future admin + * "user detail" page. Not currently wired into any route. + * + * The live GitHub-style profile route at + * `app/[lang]/s/[subdomain]/(school-dashboard)/profile/[[...id]]/page.tsx` + * uses `getProfileBasicData` from ../actions.ts, which returns a flat + * data shape and a `viewerPermission` field for client-side masking. + * + * This module returns a strictly filtered `FilteredProfileData` shape via + * `filterProfileData(profile, level)`. Wire it up when the admin + * user-management page exists. See ../detail/permissions.ts. + */ import { ACTION_ERRORS, actionError } from "@/lib/action-errors" import { db } from "@/lib/db" import { currentUser } from "@/components/auth/auth" @@ -177,10 +190,7 @@ export async function getProfileById(userId: string) { // Multi-tenant check - ensure user belongs to viewer's school (unless viewer is DEVELOPER) if (viewer?.role !== "DEVELOPER") { if (user.schoolId !== viewer?.schoolId) { - return { - success: false, - error: "Unauthorized - User does not belong to your school", - } + return actionError(ACTION_ERRORS.UNAUTHORIZED) } } @@ -227,10 +237,7 @@ export async function getProfileById(userId: string) { } } catch (error) { console.error("Error fetching profile:", error) - return { - success: false, - error: "Failed to fetch profile data", - } + return actionError(ACTION_ERRORS.LOAD_FAILED) } } diff --git a/src/components/school-dashboard/profile/edit-role-actions.ts b/src/components/school-dashboard/profile/edit-role-actions.ts index eae333ce8..969b32b3e 100644 --- a/src/components/school-dashboard/profile/edit-role-actions.ts +++ b/src/components/school-dashboard/profile/edit-role-actions.ts @@ -25,12 +25,12 @@ export async function getOwnEntity( try { const session = await auth() if (!session?.user?.id) { - return { success: false, error: "Not authenticated" } + return { success: false, error: "NOT_AUTHENTICATED" } } const { schoolId } = await getTenantContext() if (!schoolId) { - return { success: false, error: "Missing school context" } + return { success: false, error: "MISSING_SCHOOL" } } if (entityType === "teacher") { @@ -48,7 +48,7 @@ export async function getOwnEntity( }) if (!teacher) { - return { success: false, error: "Teacher profile not found" } + return { success: false, error: "TEACHER_NOT_FOUND" } } return { @@ -66,7 +66,7 @@ export async function getOwnEntity( }) if (!student) { - return { success: false, error: "Student profile not found" } + return { success: false, error: "STUDENT_NOT_FOUND" } } return { @@ -78,12 +78,10 @@ export async function getOwnEntity( } } - return { success: false, error: "Invalid entity type" } + return { success: false, error: "VALIDATION_ERROR" } } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to load profile", - } + console.error("Error loading profile entity:", error) + return { success: false, error: "LOAD_FAILED" } } } diff --git a/src/lib/action-errors.ts b/src/lib/action-errors.ts index a29b45daa..51652af04 100644 --- a/src/lib/action-errors.ts +++ b/src/lib/action-errors.ts @@ -21,6 +21,7 @@ export const ACTION_ERRORS = { ALREADY_EXISTS: "ALREADY_EXISTS", RATE_LIMITED: "RATE_LIMITED", UNKNOWN: "UNKNOWN", + NOT_IMPLEMENTED: "NOT_IMPLEMENTED", // CRUD operations (generic) CREATE_FAILED: "CREATE_FAILED", diff --git a/tests/e2e/profile/profile-flows.spec.ts b/tests/e2e/profile/profile-flows.spec.ts new file mode 100644 index 000000000..2a5d08e15 --- /dev/null +++ b/tests/e2e/profile/profile-flows.spec.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2025-present databayt +// Licensed under SSPL-1.0 -- see LICENSE for details + +/** + * Profile E2E flows. + * + * Coverage: + * 1. Authenticated user reaches /profile and sees their own GitHub-style profile + * 2. Profile sidebar renders the avatar, name, role, achievements + * 3. Edit profile button is visible to the owner only + * 4. Profile of another user (admin viewing a teacher) renders without an Edit button + * 5. Unauthenticated request to /profile redirects to login + * + * Test data: seeded demo school accounts (admin@databayt.org, teacher@databayt.org). + * Auth: uses the persistent `tests/.auth/{role}.json` storage state from auth.setup.ts. + */ + +import { expect, test } from "@playwright/test" + +import { TEST_USERS } from "../../helpers/test-data" + +const DEMO_HOST = "demo.localhost:3000" + +function profileUrl(host = DEMO_HOST) { + return `http://${host}/en/profile` +} + +function profileForId(userId: string, host = DEMO_HOST) { + return `http://${host}/en/profile/${userId}` +} + +test.describe("Profile — own profile (admin)", () => { + test.use({ storageState: "tests/.auth/admin.json" }) + + test("renders the owner's profile with the Edit button", async ({ page }) => { + const res = await page.goto(profileUrl()) + if (!res || res.status() >= 400) { + test.skip(true, `Profile route unreachable: ${res?.status()}`) + return + } + + // Sidebar — name + role chip + at least one stat + await expect(page.getByRole("heading", { level: 1 })).toBeVisible() + await expect( + page.getByRole("button", { name: /edit profile/i }) + ).toBeVisible() + + // Tabs + await expect(page.getByRole("tab", { name: /overview/i })).toBeVisible() + }) + + test("opens the edit form and validates required fields", async ({ + page, + }) => { + const res = await page.goto(profileUrl()) + if (!res || res.status() >= 400) { + test.skip(true, "Profile route unreachable") + return + } + + await page.getByRole("button", { name: /edit profile/i }).click() + + // The edit form should appear in place of the static info. + // We don't submit (avoid mutating seed data); we verify the form opened. + await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible({ + timeout: 5_000, + }) + }) +}) + +test.describe("Profile — viewing other users", () => { + test.use({ storageState: "tests/.auth/admin.json" }) + + test("admin viewing a teacher sees the profile but no Edit button", async ({ + page, + }) => { + // Admin first navigates to the teachers listing to discover a real teacher id. + const teacherListing = `http://${DEMO_HOST}/en/teachers` + const listingRes = await page.goto(teacherListing) + if (!listingRes || listingRes.status() >= 400) { + test.skip(true, "Teachers listing unreachable") + return + } + + // Pick the first teacher row that exposes a profile link via data-testid or href. + // Falls back to skipping if the listing has no rows. + const firstProfileLink = page.locator('a[href*="/profile/"]').first() + const href = await firstProfileLink.getAttribute("href").catch(() => null) + if (!href) { + test.skip(true, "No teacher profile link found in listing") + return + } + + await page.goto(`http://${DEMO_HOST}${href}`) + await expect(page.getByRole("heading", { level: 1 })).toBeVisible() + await expect( + page.getByRole("button", { name: /edit profile/i }) + ).toHaveCount(0) + }) +}) + +test.describe("Profile — auth gate", () => { + test("unauthenticated request redirects to login", async ({ browser }) => { + const ctx = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }) + const page = await ctx.newPage() + const res = await page.goto(profileUrl()) + + // Either we land on a login page, or the route returns an auth-required status + if (res && res.status() >= 400) { + expect([401, 403]).toContain(res.status()) + } else { + await expect(page).toHaveURL(/\/(login|en\/login)/, { timeout: 10_000 }) + } + await ctx.close() + }) +}) + +test.describe("Profile — self-service from teacher account", () => { + test.use({ storageState: "tests/.auth/teacher.json" }) + + test("teacher reaches own profile and sees role-specific tabs", async ({ + page, + }) => { + const res = await page.goto(profileUrl()) + if (!res || res.status() >= 400) { + test.skip(true, "Profile route unreachable") + return + } + + await expect(page.getByRole("tab", { name: /overview/i })).toBeVisible() + await expect(page.getByRole("tab", { name: /schedule/i })).toBeVisible() + }) + + test("teacher sees own Edit button (is owner)", async ({ page }) => { + const res = await page.goto(profileUrl()) + if (!res || res.status() >= 400) { + test.skip(true, "Profile route unreachable") + return + } + await expect( + page.getByRole("button", { name: /edit profile/i }) + ).toBeVisible() + }) +}) + +// Sanity guard: keep the test seed credentials in sync with what auth.setup.ts uses. +test("test credentials sanity check", () => { + expect(TEST_USERS.admin.email).toBe("admin@databayt.org") + expect(TEST_USERS.teacher.email).toBe("teacher@databayt.org") +})