From 6b48a80c0f31558d187a96c4cbb1f31c2afa8f8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 15:15:58 +0000 Subject: [PATCH 1/8] feat(onboarding): add processed avatar uploads and profile refresh Co-authored-by: Tiankai Ma --- docs/contracts/user.json | 7 +- messages/en-us.json | 17 +++ messages/zh-cn.json | 17 +++ .../profile/server/profile-avatar-service.ts | 129 ++++++++++++++++++ .../profile/server/profile-update-service.ts | 2 + .../components/WelcomeNextStepsCard.svelte | 14 ++ .../components/WelcomeOAuthProfileCard.svelte | 40 ++++++ .../components/WelcomePageController.svelte | 38 ++++-- .../components/WelcomeProfileForm.svelte | 30 +++- .../components/welcome-component-types.ts | 4 + .../welcome/server/welcome-complete-action.ts | 65 +++++++-- .../server/welcome-oauth-refresh-action.ts | 79 +++++++++++ .../welcome/server/welcome-page-server.ts | 30 +++- .../welcome/server/welcome-profile-form.ts | 5 + src/lib/adapters/cloudflare-runtime.ts | 28 ++++ src/lib/auth/auth-routing.ts | 1 + src/lib/auth/oauth-profile-mappers.ts | 52 +++---- src/lib/auth/social-verified-email-plugin.ts | 37 +++-- src/lib/auth/social-verified-email-staging.ts | 2 +- .../[userId]/[avatarId].webp/+server.ts | 15 ++ wrangler.dev.jsonc | 3 + wrangler.e2e.jsonc | 3 + wrangler.jsonc | 3 + 23 files changed, 559 insertions(+), 62 deletions(-) create mode 100644 src/features/profile/server/profile-avatar-service.ts create mode 100644 src/features/welcome/components/WelcomeOAuthProfileCard.svelte create mode 100644 src/features/welcome/server/welcome-oauth-refresh-action.ts create mode 100644 src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts diff --git a/docs/contracts/user.json b/docs/contracts/user.json index 195ce13ae..936cb496c 100644 --- a/docs/contracts/user.json +++ b/docs/contracts/user.json @@ -13,6 +13,8 @@ "debug-password-boundary": "Email/password authentication is enabled only for local development or the explicit non-production E2E debug mode; production does not expose debug password authentication.", "oauth-callback-integrity": "On a canonical same-origin deployment, OAuth login callbacks must complete state validation and session creation directly, without losing state due to proxy wrapping or container-internal host differences.", "welcome-flow-required": "New users who have not set a name or username on first login must complete the welcome flow before proceeding.", + "welcome-avatar-upload": "During welcome, signed-in users may upload a JPG, PNG, WebP, or AVIF avatar up to 5 MB. The Worker uses the Cloudflare Images binding to auto-crop it to a 256-by-256 WebP before storing it in private R2; public avatar delivery is limited to URLs currently referenced by that user's profile.", + "welcome-oauth-refresh": "A user may reauthorize an already linked OAuth provider during welcome to fetch current upstream name and avatar options. Refreshed upstream values fill missing data and add avatar choices without overwriting profile values the user entered.", "welcome-completion-resume": "If the welcome flow was reached from an app-relative callbackUrl, successful completion returns to that page; otherwise completion returns home.", "auth-callback-not-intercepted": "Authorization callback continuation requests carrying OAuth code/error and state must be allowed to complete the protocol redirect; the welcome page interceptor must not break the authorization result redirect.", "post-login-redirect": "After successful login, the user should be redirected back to the originally requested app-relative page whenever possible; unsafe external callbacks fall back to the home page.", @@ -58,8 +60,11 @@ "user.username (Current value display)", "user.image (Current avatar)", "user.profilePictures[] (Avatar selector grid)", + "Custom avatar upload (server-cropped 256x256 WebP in R2)", + "Linked OAuth providers (reauthorize to refresh upstream profile options)", "semesters[] (Semester dropdown options)", - "defaultSemesterId (Preselected semester)" + "defaultSemesterId (Preselected semester)", + "Platform guidance for subscriptions, workspace, planning, and account security" ] } }, diff --git a/messages/en-us.json b/messages/en-us.json index a4690e320..e523bc45a 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -1337,6 +1337,8 @@ "editProfile": "Edit Profile", "editProfileDescription": "Make changes to your profile here. Click save when you're done.", "profilePicture": "Profile Picture", + "avatarUpload": "Upload your own avatar", + "avatarUploadHint": "JPG, PNG, WebP, or AVIF up to 5 MB. Cloudflare center-crops it to 256×256 and converts it to WebP.", "name": "Name", "namePlaceholder": "Your name", "nameRequired": "Name is required.", @@ -2006,6 +2008,21 @@ "browseSections": "Browse Sections", "browseCourses": "Browse Courses", "avatarLater": "You can add uploaded avatars later from settings.", + "avatarUploadInvalid": "Choose a valid JPG, PNG, WebP, or AVIF image.", + "avatarUploadTooLarge": "Avatar files must be 5 MB or smaller.", + "avatarUploadUnavailable": "Avatar processing is temporarily unavailable. Try again later or use an upstream avatar.", + "oauthRefreshTitle": "Refresh from a sign-in account", + "oauthRefreshDescription": "Reauthorizing fetches the latest upstream name and avatar as options without overwriting details you entered yourself.", + "oauthRefreshAction": "Refresh from {provider}", + "oauthRefreshSuccess": "Latest upstream details received. Review your name and avatar, then continue.", + "oauthRefreshFailed": "We couldn't refresh details from that sign-in account. Please try again.", + "oauthRefreshNotLinked": "That sign-in account is not linked to the current user.", + "guideWorkspaceTitle": "Subscriptions and workspace", + "guideWorkspaceDescription": "Subscribe to sections to bring homework, exams, and course events into your workspace.", + "guidePlanningTitle": "Calendar and todos", + "guidePlanningDescription": "See course events beside personal todos, or export an iCal feed to your usual calendar.", + "guideAccountTitle": "Account and security", + "guideAccountDescription": "After setup, link sign-in accounts, add a passkey, and manage authorized applications.", "bulkImportCta": "Bulk Add Subscriptions", "sectionCodesLabel": "Section codes", "confirmImportTitle": "Confirm Section Subscriptions", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 1160874ad..aec925151 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1303,6 +1303,8 @@ "editProfile": "编辑资料", "editProfileDescription": "在此处更改您的个人资料。完成后点击保存。", "profilePicture": "头像", + "avatarUpload": "上传自己的头像", + "avatarUploadHint": "支持 JPG、PNG、WebP 或 AVIF,最大 5 MB。图片会在 Cloudflare 中心裁切为 256×256 并转换为 WebP。", "name": "姓名", "namePlaceholder": "您的姓名", "nameRequired": "请输入姓名。", @@ -1972,6 +1974,21 @@ "browseSections": "浏览班级", "browseCourses": "浏览课程", "avatarLater": "你可以稍后在设置中添加上传过的头像。", + "avatarUploadInvalid": "请选择有效的 JPG、PNG、WebP 或 AVIF 图片。", + "avatarUploadTooLarge": "头像文件不能超过 5 MB。", + "avatarUploadUnavailable": "头像处理服务暂时不可用,请稍后重试或先使用上游头像。", + "oauthRefreshTitle": "从登录账户更新资料", + "oauthRefreshDescription": "重新授权后会获取最新的上游姓名和头像作为可选资料,不会覆盖你已经手动填写的内容。", + "oauthRefreshAction": "从 {provider} 重新获取", + "oauthRefreshSuccess": "已获取最新的上游资料,请检查姓名和头像后继续。", + "oauthRefreshFailed": "暂时无法从该登录账户更新资料,请稍后重试。", + "oauthRefreshNotLinked": "该登录账户未与当前用户关联。", + "guideWorkspaceTitle": "订阅与工作区", + "guideWorkspaceDescription": "订阅教学班后,作业、考试和课程日历会汇总到你的工作区。", + "guidePlanningTitle": "日历与待办", + "guidePlanningDescription": "把课程事件与个人待办放在一起查看,也可以导出 iCal 到常用日历。", + "guideAccountTitle": "账户与安全", + "guideAccountDescription": "完成设置后可关联多个登录账户、添加通行密钥并管理应用授权。", "bulkImportCta": "批量添加订阅", "sectionCodesLabel": "班级代码", "confirmImportTitle": "确认添加教学班订阅", diff --git a/src/features/profile/server/profile-avatar-service.ts b/src/features/profile/server/profile-avatar-service.ts new file mode 100644 index 000000000..0a60f3942 --- /dev/null +++ b/src/features/profile/server/profile-avatar-service.ts @@ -0,0 +1,129 @@ +import { prisma } from "@/lib/db/prisma"; +import { + deleteStorageObject, + getStorageObjectResponse, + putStorageObject, +} from "@/lib/storage/r2-object"; +import { getCloudflareImagesBinding } from "@/lib/adapters/cloudflare-runtime"; + +export const PROFILE_AVATAR_DIMENSION = 256; +export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024; +export const PROFILE_AVATAR_OUTPUT_TYPE = "image/webp"; + +const PROFILE_AVATAR_INPUT_TYPES = new Set([ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", +]); +const AVATAR_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type ProfileAvatarUploadFailure = + | "empty" + | "invalid_image" + | "too_large" + | "unavailable"; + +export class ProfileAvatarUploadError extends Error { + constructor(readonly reason: ProfileAvatarUploadFailure) { + super(`Profile avatar upload failed: ${reason}`); + this.name = "ProfileAvatarUploadError"; + } +} + +function avatarUrl(userId: string, avatarId: string) { + return `/media/avatars/${encodeURIComponent(userId)}/${avatarId}.webp`; +} + +function avatarKey(userId: string, avatarId: string) { + return `avatars/${userId}/${avatarId}.webp`; +} + +export async function processProfileAvatarUpload(input: { + file: File; + userId: string; +}) { + if (input.file.size <= 0) { + throw new ProfileAvatarUploadError("empty"); + } + if (input.file.size > PROFILE_AVATAR_MAX_BYTES) { + throw new ProfileAvatarUploadError("too_large"); + } + if (!PROFILE_AVATAR_INPUT_TYPES.has(input.file.type.toLowerCase())) { + throw new ProfileAvatarUploadError("invalid_image"); + } + + const images = getCloudflareImagesBinding(); + if (!images) { + throw new ProfileAvatarUploadError("unavailable"); + } + + const avatarId = crypto.randomUUID(); + const key = avatarKey(input.userId, avatarId); + try { + const transformed = await images + .input(input.file.stream()) + .transform({ + width: PROFILE_AVATAR_DIMENSION, + height: PROFILE_AVATAR_DIMENSION, + fit: "cover", + gravity: "auto", + }) + .output({ + format: PROFILE_AVATAR_OUTPUT_TYPE, + quality: 85, + }); + const response = transformed.response(); + if (!response.ok || !response.body) { + throw new ProfileAvatarUploadError("invalid_image"); + } + await putStorageObject({ + body: response.body, + contentType: PROFILE_AVATAR_OUTPUT_TYPE, + key, + }); + } catch (error) { + if (error instanceof ProfileAvatarUploadError) throw error; + const code = + error && typeof error === "object" && "code" in error + ? Number(error.code) + : null; + throw new ProfileAvatarUploadError( + code === 9412 ? "invalid_image" : "unavailable", + ); + } + + return { + key, + url: avatarUrl(input.userId, avatarId), + }; +} + +export async function deleteProcessedProfileAvatar(key: string) { + await deleteStorageObject(key); +} + +export async function getPublicProfileAvatar(input: { + avatarId: string; + userId: string; +}) { + if (!AVATAR_ID_PATTERN.test(input.avatarId)) return null; + const url = avatarUrl(input.userId, input.avatarId); + const user = await prisma.user.findUnique({ + where: { id: input.userId }, + select: { image: true, profilePictures: true }, + }); + if (!user || (user.image !== url && !user.profilePictures.includes(url))) { + return null; + } + + const response = await getStorageObjectResponse({ + contentDisposition: 'inline; filename="avatar.webp"', + contentType: PROFILE_AVATAR_OUTPUT_TYPE, + key: avatarKey(input.userId, input.avatarId), + }); + if (!response) return null; + response.headers.set("Cache-Control", "public, max-age=31536000, immutable"); + return response; +} diff --git a/src/features/profile/server/profile-update-service.ts b/src/features/profile/server/profile-update-service.ts index 47f156cf8..f712adf59 100644 --- a/src/features/profile/server/profile-update-service.ts +++ b/src/features/profile/server/profile-update-service.ts @@ -7,6 +7,7 @@ type ProfileUpdateInput = { headers: Headers; image: string | null; name: string; + trustedImageUrl?: string | null; userId: string; username: string; }; @@ -38,6 +39,7 @@ export async function updateOwnProfile( if ( input.image && input.image !== current.image && + input.image !== input.trustedImageUrl && !current.profilePictures.includes(input.image) ) { return { ok: false, reason: "avatar_invalid" }; diff --git a/src/features/welcome/components/WelcomeNextStepsCard.svelte b/src/features/welcome/components/WelcomeNextStepsCard.svelte index d4c2691f0..94eb99df4 100644 --- a/src/features/welcome/components/WelcomeNextStepsCard.svelte +++ b/src/features/welcome/components/WelcomeNextStepsCard.svelte @@ -25,5 +25,19 @@ export let welcomeCopy: WelcomeCopy; +
+
+

{welcomeCopy.guideWorkspaceTitle}

+

{welcomeCopy.guideWorkspaceDescription}

+
+
+

{welcomeCopy.guidePlanningTitle}

+

{welcomeCopy.guidePlanningDescription}

+
+
+

{welcomeCopy.guideAccountTitle}

+

{welcomeCopy.guideAccountDescription}

+
+
diff --git a/src/features/welcome/components/WelcomeOAuthProfileCard.svelte b/src/features/welcome/components/WelcomeOAuthProfileCard.svelte new file mode 100644 index 000000000..f82d63c6a --- /dev/null +++ b/src/features/welcome/components/WelcomeOAuthProfileCard.svelte @@ -0,0 +1,40 @@ + + +{#if oauthProviders.length > 0} + + + {welcomeCopy.oauthRefreshTitle} + {welcomeCopy.oauthRefreshDescription} + + + {#if oauthRefreshed} + + {welcomeCopy.oauthRefreshSuccess} + + {/if} +
+ {#each oauthProviders as provider} +
+ + + +
+ {/each} +
+
+
+{/if} diff --git a/src/features/welcome/components/WelcomePageController.svelte b/src/features/welcome/components/WelcomePageController.svelte index 67e51a68e..831290ac1 100644 --- a/src/features/welcome/components/WelcomePageController.svelte +++ b/src/features/welcome/components/WelcomePageController.svelte @@ -1,6 +1,7 @@ -
+ @@ -62,7 +77,7 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {profileCopy.profilePicture}
- + {avatarFallback} {#if avatarOptions.length > 0} @@ -94,6 +109,17 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {/if}
+ + {profileCopy.avatarUpload} + + {profileCopy.avatarUploadHint} + diff --git a/src/features/welcome/components/welcome-component-types.ts b/src/features/welcome/components/welcome-component-types.ts index 50ec94aaa..2d4fa328a 100644 --- a/src/features/welcome/components/welcome-component-types.ts +++ b/src/features/welcome/components/welcome-component-types.ts @@ -2,6 +2,8 @@ import type { SubmitFunction } from "@sveltejs/kit"; import type { WelcomeMatchedSection } from "@/features/welcome/lib/welcome-bulk-import-types"; export type WelcomeProfileCopy = { + avatarUpload: string; + avatarUploadHint: string; name: string; namePlaceholder: string; profilePicture: string; @@ -87,6 +89,8 @@ export type WelcomePageData = { copy: WelcomePageCopy; defaultSemesterId?: number | string | null; locale: string; + oauthProviders: Array<{ id: string; name: string }>; + oauthRefreshed: boolean; semesters: WelcomeSemester[]; user: WelcomePageUser; }; diff --git a/src/features/welcome/server/welcome-complete-action.ts b/src/features/welcome/server/welcome-complete-action.ts index 79c15ed73..2264fbfa0 100644 --- a/src/features/welcome/server/welcome-complete-action.ts +++ b/src/features/welcome/server/welcome-complete-action.ts @@ -1,5 +1,10 @@ import type { Cookies } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit"; +import { + deleteProcessedProfileAvatar, + processProfileAvatarUpload, + ProfileAvatarUploadError, +} from "@/features/profile/server/profile-avatar-service"; import { updateOwnProfile } from "@/features/profile/server/profile-update-service"; import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; import { getSessionFromHeaders } from "@/lib/auth/core"; @@ -8,6 +13,14 @@ import { resolveWelcomeCallbackUrl } from "./welcome-callback-url"; import { getWelcomeCopy } from "./welcome-page-copy"; import { parseWelcomeProfileForm } from "./welcome-profile-form"; +async function discardUploadedAvatar(key: string) { + try { + await deleteProcessedProfileAvatar(key); + } catch (error) { + void error; + } +} + export async function completeWelcomeProfile({ locals, request, @@ -19,7 +32,8 @@ export async function completeWelcomeProfile({ }) { const copy = getWelcomeCopy(locals.locale); const form = await request.formData(); - const { callbackUrl, image, name, username } = parseWelcomeProfileForm(form); + const { avatar, callbackUrl, image, name, username } = + parseWelcomeProfileForm(form); const redirectTo = resolveWelcomeCallbackUrl(callbackUrl); const session = await getSessionFromHeaders(request.headers); if (!session?.user?.id) { @@ -31,14 +45,49 @@ export async function completeWelcomeProfile({ ); } - const result = await updateOwnProfile({ - headers: request.headers, - image, - name, - userId: session.user.id, - username, - }); + let uploadedAvatar: Awaited< + ReturnType + > | null = null; + try { + if (avatar) { + uploadedAvatar = await processProfileAvatarUpload({ + file: avatar, + userId: session.user.id, + }); + } + } catch (error) { + if (error instanceof ProfileAvatarUploadError) { + const message = + error.reason === "too_large" + ? copy.welcome.avatarUploadTooLarge + : error.reason === "unavailable" + ? copy.welcome.avatarUploadUnavailable + : copy.welcome.avatarUploadInvalid; + return fail(400, { message }); + } + throw error; + } + + let result: Awaited>; + try { + result = await updateOwnProfile({ + headers: request.headers, + image: uploadedAvatar?.url ?? image, + name, + trustedImageUrl: uploadedAvatar?.url, + userId: session.user.id, + username, + }); + } catch (error) { + if (uploadedAvatar) { + await discardUploadedAvatar(uploadedAvatar.key); + } + throw error; + } if (!result.ok) { + if (uploadedAvatar) { + await discardUploadedAvatar(uploadedAvatar.key); + } if (result.reason === "name_required") { return fail(400, { message: copy.profile.nameRequired }); } diff --git a/src/features/welcome/server/welcome-oauth-refresh-action.ts b/src/features/welcome/server/welcome-oauth-refresh-action.ts new file mode 100644 index 000000000..54a04540c --- /dev/null +++ b/src/features/welcome/server/welcome-oauth-refresh-action.ts @@ -0,0 +1,79 @@ +import { type Cookies, fail, redirect } from "@sveltejs/kit"; +import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; +import { getSessionFromHeaders } from "@/lib/auth/core"; +import { linkAccountFromSvelteAction } from "@/lib/auth/svelte-auth-actions"; +import { prisma } from "@/lib/db/prisma"; +import { logServerActionError } from "@/lib/log/app-logger"; +import { resolveWelcomeCallbackUrl } from "./welcome-callback-url"; +import { getWelcomeCopy } from "./welcome-page-copy"; + +const REFRESHABLE_PROVIDERS = new Set(["github", "google", "oidc"]); + +export async function refreshWelcomeOAuthProfile({ + cookies, + locals, + request, +}: { + cookies: Cookies; + locals: App.Locals; + request: Request; +}) { + const form = await request.formData(); + const callbackUrl = resolveWelcomeCallbackUrl(form.get("callbackUrl")); + const session = await getSessionFromHeaders(request.headers); + if (!session?.user?.id) { + throw redirect( + 303, + buildSignInPageUrl( + `/account/welcome?callbackUrl=${encodeURIComponent(callbackUrl)}`, + ), + ); + } + + const providerId = String(form.get("providerId") ?? ""); + if (!REFRESHABLE_PROVIDERS.has(providerId)) { + return fail(400, { + message: getWelcomeCopy(locals.locale).welcome.oauthRefreshFailed, + }); + } + const linkedAccount = await prisma.account.findFirst({ + where: { + userId: session.user.id, + provider: providerId, + }, + select: { id: true }, + }); + if (!linkedAccount) { + return fail(400, { + message: getWelcomeCopy(locals.locale).welcome.oauthRefreshNotLinked, + }); + } + + const returnTo = `/account/welcome?callbackUrl=${encodeURIComponent(callbackUrl)}&oauthRefreshed=1`; + try { + const result = await linkAccountFromSvelteAction({ + providerId, + callbackUrl: returnTo, + headers: request.headers, + cookies, + }); + throw redirect(303, result.url); + } catch (error) { + if ( + error && + typeof error === "object" && + "status" in error && + "location" in error + ) { + throw error; + } + logServerActionError("auth.welcome_oauth_refresh.failed", error, { + action: "refresh-oauth-profile", + requestId: locals.requestId, + route: "/account/welcome", + }); + return fail(400, { + message: getWelcomeCopy(locals.locale).welcome.oauthRefreshFailed, + }); + } +} diff --git a/src/features/welcome/server/welcome-page-server.ts b/src/features/welcome/server/welcome-page-server.ts index 15ad15ed9..e5169ce99 100644 --- a/src/features/welcome/server/welcome-page-server.ts +++ b/src/features/welcome/server/welcome-page-server.ts @@ -1,4 +1,5 @@ import { redirect, type ServerLoadEvent } from "@sveltejs/kit"; +import { providerNames } from "@/features/auth/server/signin-page-copy"; import { getCurrentSemester } from "@/features/catalog/server/academic-metadata-read-model"; import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; import { getSessionFromHeaders } from "@/lib/auth/core"; @@ -6,6 +7,9 @@ import { prisma } from "@/lib/db/prisma"; import { resolveWelcomeCallbackUrl } from "./welcome-callback-url"; import { completeWelcomeProfile } from "./welcome-complete-action"; import { getWelcomeCopy } from "./welcome-page-copy"; +import { refreshWelcomeOAuthProfile } from "./welcome-oauth-refresh-action"; + +const REFRESHABLE_PROVIDERS = new Set(["github", "google", "oidc"]); export const loadWelcomePage = async ({ locals, @@ -34,6 +38,9 @@ export const loadWelcomePage = async ({ username: true, image: true, profilePictures: true, + accounts: { + select: { provider: true }, + }, }, }), prisma.semester.findMany({ @@ -58,7 +65,27 @@ export const loadWelcomePage = async ({ } return { - user, + user: { + id: user.id, + name: user.name, + username: user.username, + image: user.image, + profilePictures: user.profilePictures, + }, + oauthProviders: Array.from( + new Set( + user.accounts + .map(({ provider }) => provider) + .filter((provider) => REFRESHABLE_PROVIDERS.has(provider)), + ), + ).map((id) => ({ + id, + name: + providerNames(locals.locale)[ + id as keyof ReturnType + ] ?? id, + })), + oauthRefreshed: url.searchParams.get("oauthRefreshed") === "1", semesters, defaultSemesterId: currentSemester?.id ?? null, callbackUrl, @@ -69,4 +96,5 @@ export const loadWelcomePage = async ({ export const welcomeActions = { complete: completeWelcomeProfile, + refreshOAuth: refreshWelcomeOAuthProfile, }; diff --git a/src/features/welcome/server/welcome-profile-form.ts b/src/features/welcome/server/welcome-profile-form.ts index d15859578..125791251 100644 --- a/src/features/welcome/server/welcome-profile-form.ts +++ b/src/features/welcome/server/welcome-profile-form.ts @@ -1,7 +1,12 @@ export function parseWelcomeProfileForm(form: FormData) { const submittedImage = form.get("image"); + const submittedAvatar = form.get("avatar"); return { + avatar: + submittedAvatar instanceof File && submittedAvatar.size > 0 + ? submittedAvatar + : null, callbackUrl: String(form.get("callbackUrl") ?? "").trim(), name: String(form.get("name") ?? "").trim(), username: String(form.get("username") ?? "").trim(), diff --git a/src/lib/adapters/cloudflare-runtime.ts b/src/lib/adapters/cloudflare-runtime.ts index 2b952b33a..49fb79425 100644 --- a/src/lib/adapters/cloudflare-runtime.ts +++ b/src/lib/adapters/cloudflare-runtime.ts @@ -26,6 +26,29 @@ export type CloudflareR2Bucket = { ): Promise; }; +export type CloudflareImageTransformationResult = { + contentType(): string; + image(): ReadableStream; + response(): Response; +}; + +export type CloudflareImageTransformer = { + output(options: { + format: "image/webp"; + quality?: number; + }): Promise; + transform(options: { + fit: "cover"; + gravity?: "auto" | "center" | "face"; + height: number; + width: number; + }): CloudflareImageTransformer; +}; + +export type CloudflareImagesBinding = { + input(stream: ReadableStream): CloudflareImageTransformer; +}; + export type CloudflareAnalyticsEngineDataPoint = { blobs?: ((ArrayBuffer | string) | null)[]; doubles?: number[]; @@ -105,6 +128,7 @@ type CloudflareRuntimeEnv = Record & { HYPERDRIVE_AUTH?: { connectionString?: unknown; }; + IMAGES?: CloudflareImagesBinding; R2_UPLOADS?: CloudflareR2Bucket; USER_BATCH_WRITE_RATE_LIMITER?: CloudflareRateLimiter; USER_WRITE_RATE_LIMITER?: CloudflareRateLimiter; @@ -274,6 +298,10 @@ export function getCloudflareR2UploadsBucket() { return getCurrentCloudflareRuntimeEnv()?.R2_UPLOADS; } +export function getCloudflareImagesBinding() { + return getCurrentCloudflareRuntimeEnv()?.IMAGES; +} + export function getCloudflareAnalyticsEngineDataset() { return getCurrentCloudflareRuntimeEnv()?.ANALYTICS; } diff --git a/src/lib/auth/auth-routing.ts b/src/lib/auth/auth-routing.ts index 1a7d52ce3..1c8184a38 100644 --- a/src/lib/auth/auth-routing.ts +++ b/src/lib/auth/auth-routing.ts @@ -106,6 +106,7 @@ function isOAuthCallbackContinuation(url: URL): boolean { function isNonPageRequestPath(pathname: string): boolean { return ( pathname.startsWith("/api/") || + pathname.startsWith("/media/") || pathname.startsWith("/.well-known/") || pathname.startsWith("/_app/") || pathname === "/llms.txt" || diff --git a/src/lib/auth/oauth-profile-mappers.ts b/src/lib/auth/oauth-profile-mappers.ts index 0ea993ca4..7ef378c55 100644 --- a/src/lib/auth/oauth-profile-mappers.ts +++ b/src/lib/auth/oauth-profile-mappers.ts @@ -27,10 +27,20 @@ export function mapOidcProfileToUser(profile: OAuthProfile) { "email", ]) ?? `USTC User ${accountId}`; + const image = profileImage(profile.picture); + stageSocialVerifiedEmail({ + provider: "oidc", + accountId, + email: null, + emailVerified: false, + name: displayName, + image: image ?? null, + }); + return { email: fallbackEmail("oidc", accountId), name: displayName, - image: profileImage(profile.picture), + image, emailVerified: false, }; } @@ -45,18 +55,16 @@ export function getOidcAccountSubject(profile: OAuthProfile) { export function mapGithubProfileToUser(profile: GithubProfile) { const email = profileEmail(profile.email); - if (isPublishableUserEmail(email)) { - stageSocialVerifiedEmail({ - provider: "github", - accountId: String(profile.id), - email, - // GitHub user:email returns account mailboxes; treat as verified for - // OAuth client publication once stored in VerifiedEmail. - emailVerified: true, - name: profileName(profile.name ?? profile.login) || null, - image: profileImage(profile.avatar_url) ?? null, - }); - } + stageSocialVerifiedEmail({ + provider: "github", + accountId: String(profile.id), + email: isPublishableUserEmail(email) ? email : null, + // GitHub user:email returns account mailboxes; treat as verified for + // OAuth client publication once stored in VerifiedEmail. + emailVerified: isPublishableUserEmail(email), + name: profileName(profile.name ?? profile.login) || null, + image: profileImage(profile.avatar_url) ?? null, + }); return { email: email ?? fallbackEmail("github", profile.id), @@ -73,16 +81,14 @@ export function mapGoogleProfileToUser(profile: GoogleProfile) { ? profile.email_verified : false; - if (isPublishableUserEmail(email) && emailVerified) { - stageSocialVerifiedEmail({ - provider: "google", - accountId: profile.sub, - email, - emailVerified: true, - name: profileName(profile.name) || null, - image: profileImage(profile.picture) ?? null, - }); - } + stageSocialVerifiedEmail({ + provider: "google", + accountId: profile.sub, + email: isPublishableUserEmail(email) && emailVerified ? email : null, + emailVerified, + name: profileName(profile.name) || null, + image: profileImage(profile.picture) ?? null, + }); return { email: email ?? fallbackEmail("google", profile.sub), diff --git a/src/lib/auth/social-verified-email-plugin.ts b/src/lib/auth/social-verified-email-plugin.ts index 3c368936e..273eed286 100644 --- a/src/lib/auth/social-verified-email-plugin.ts +++ b/src/lib/auth/social-verified-email-plugin.ts @@ -7,7 +7,7 @@ import { upsertVerifiedEmail } from "@/lib/auth/oauth-user-email-resolve"; import { consumeStagedSocialVerifiedEmail } from "@/lib/auth/social-verified-email-staging"; import { authPrisma } from "@/lib/db/auth-prisma"; -const SOCIAL_VERIFIED_EMAIL_PROVIDERS = new Set(["github", "google"]); +const SOCIAL_PROFILE_PROVIDERS = new Set(["github", "google", "oidc"]); type AccountHookPayload = Pick< Account, @@ -16,14 +16,14 @@ type AccountHookPayload = Pick< async function applySocialVerifiedEmailToUser(input: { userId: string; - email: string; + email: string | null; emailVerified: boolean; name: string | null; image: string | null; }) { const current = await authPrisma.user.findUnique({ where: { id: input.userId }, - select: { email: true, name: true, image: true }, + select: { email: true, name: true, image: true, profilePictures: true }, }); if (!current) return; @@ -32,9 +32,14 @@ async function applySocialVerifiedEmailToUser(input: { emailVerified?: boolean; name?: string; image?: string | null; + profilePictures?: { push: string }; } = {}; - if (isPlaceholderUserEmail(current.email)) { + if ( + input.email && + isPublishableUserEmail(input.email) && + isPlaceholderUserEmail(current.email) + ) { profileUpdate.email = input.email; profileUpdate.emailVerified = input.emailVerified; } @@ -44,6 +49,9 @@ async function applySocialVerifiedEmailToUser(input: { if (input.image && !current.image) { profileUpdate.image = input.image; } + if (input.image && !current.profilePictures.includes(input.image)) { + profileUpdate.profilePictures = { push: input.image }; + } if (Object.keys(profileUpdate).length === 0) return; @@ -61,7 +69,7 @@ async function applySocialVerifiedEmailToUser(input: { export async function syncSocialVerifiedEmailFromAccountHook( account: AccountHookPayload, ) { - if (!SOCIAL_VERIFIED_EMAIL_PROVIDERS.has(account.providerId)) return; + if (!SOCIAL_PROFILE_PROVIDERS.has(account.providerId)) return; const accountId = account.providerAccountId.trim(); if (!accountId) return; @@ -70,14 +78,19 @@ export async function syncSocialVerifiedEmailFromAccountHook( account.providerId, accountId, ); - if (!staged || !isPublishableUserEmail(staged.email)) return; + if (!staged) return; - const email = staged.email.trim(); - await upsertVerifiedEmail({ - userId: account.userId, - provider: account.providerId, - email, - }); + const email = + staged.email && isPublishableUserEmail(staged.email) + ? staged.email.trim() + : null; + if (email) { + await upsertVerifiedEmail({ + userId: account.userId, + provider: account.providerId, + email, + }); + } await applySocialVerifiedEmailToUser({ userId: account.userId, email, diff --git a/src/lib/auth/social-verified-email-staging.ts b/src/lib/auth/social-verified-email-staging.ts index e02523266..cff145eb5 100644 --- a/src/lib/auth/social-verified-email-staging.ts +++ b/src/lib/auth/social-verified-email-staging.ts @@ -1,7 +1,7 @@ export type StagedSocialVerifiedEmail = { provider: string; accountId: string; - email: string; + email: string | null; emailVerified: boolean; name: string | null; image: string | null; diff --git a/src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts b/src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts new file mode 100644 index 000000000..8fdc551f0 --- /dev/null +++ b/src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { getPublicProfileAvatar } from "@/features/profile/server/profile-avatar-service"; +import { handleRouteError, notFound } from "@/lib/api/helpers"; + +export const GET: RequestHandler = async ({ params }) => { + try { + const response = await getPublicProfileAvatar({ + avatarId: params.avatarId, + userId: params.userId, + }); + return response ?? notFound(); + } catch (error) { + return handleRouteError("Failed to load profile avatar", error); + } +}; diff --git a/wrangler.dev.jsonc b/wrangler.dev.jsonc index 100a4ed9a..6f9567c1d 100644 --- a/wrangler.dev.jsonc +++ b/wrangler.dev.jsonc @@ -21,6 +21,9 @@ "directory": ".svelte-kit/cloudflare", "binding": "ASSETS" }, + "images": { + "binding": "IMAGES" + }, "observability": { "enabled": false }, diff --git a/wrangler.e2e.jsonc b/wrangler.e2e.jsonc index 744f33a29..8c78cf229 100644 --- a/wrangler.e2e.jsonc +++ b/wrangler.e2e.jsonc @@ -38,6 +38,9 @@ "!/openapi.generated.json" ] }, + "images": { + "binding": "IMAGES" + }, "observability": { "enabled": false }, diff --git a/wrangler.jsonc b/wrangler.jsonc index 1126bb056..9a5c0167a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -30,6 +30,9 @@ "!/openapi.generated.json" ] }, + "images": { + "binding": "IMAGES" + }, "observability": { "enabled": true, "logs": { From d256e9dc2359ef76723880626ac8f99d2cd717a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 15:16:04 +0000 Subject: [PATCH 2/8] test(onboarding): cover avatar and welcome edge cases Co-authored-by: Tiankai Ma --- tests/e2e/src/app/_shared/page-inventory.ts | 12 ++ tests/e2e/src/app/signin/test.ts | 19 +++ tests/e2e/src/app/welcome/test.ts | 56 +++++++ tests/unit/oauth-profile.test.ts | 28 +++- tests/unit/profile-avatar-service.test.ts | 141 ++++++++++++++++ tests/unit/profile-update-service.test.ts | 100 ++++++++++++ tests/unit/social-verified-email-sync.test.ts | 30 +++- tests/unit/welcome-complete-action.test.ts | 153 ++++++++++++++++++ .../unit/welcome-oauth-refresh-action.test.ts | 109 +++++++++++++ tests/unit/welcome-redirect.test.ts | 1 + 10 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 tests/unit/profile-avatar-service.test.ts create mode 100644 tests/unit/welcome-complete-action.test.ts create mode 100644 tests/unit/welcome-oauth-refresh-action.test.ts diff --git a/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts index 3c17c15f0..d197a0e7c 100644 --- a/tests/e2e/src/app/_shared/page-inventory.ts +++ b/tests/e2e/src/app/_shared/page-inventory.ts @@ -233,6 +233,18 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ name: "/^(用户名|Username)\\b/i", e2eSpec: E2E.welcome, }, + { + id: "welcome-avatar-upload", + role: "button", + name: "/上传自己的头像|Upload your own avatar/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-complete", + role: "button", + name: "/继续|Continue/i", + e2eSpec: E2E.welcome, + }, { id: "bulk-import", role: "button", diff --git a/tests/e2e/src/app/signin/test.ts b/tests/e2e/src/app/signin/test.ts index a3de50e49..c470e4f58 100644 --- a/tests/e2e/src/app/signin/test.ts +++ b/tests/e2e/src/app/signin/test.ts @@ -87,6 +87,25 @@ test("/account/sign-in 显示所有必填字段", async ({ page }, testInfo) => await captureStepScreenshot(page, testInfo, "signin/all-fields"); }); +test("/account/sign-in 显示账户未关联错误", async ({ page }) => { + await gotoAndWaitForReady( + page, + "/account/sign-in?error=OAuthAccountNotLinked", + ); + await expect( + page.getByText(/此账户已关联到其他用户|already linked to another user/i), + ).toBeVisible(); +}); + +test("/account/sign-in 已登录用户直接返回回调页面", async ({ page }) => { + await signInAsDebugUser(page, "/"); + await page.goto( + "/account/sign-in?callbackUrl=%2Faccount%2Fsettings%2Fprofile", + { waitUntil: "domcontentloaded" }, + ); + await expect(page).toHaveURL(/\/account\/settings\/profile(?:\?.*)?$/); +}); + test("/account/sign-in 调试用户按钮可登录", async ({ page }, testInfo) => { await gotoAndWaitForReady(page, "/account/sign-in", { testInfo, diff --git a/tests/e2e/src/app/welcome/test.ts b/tests/e2e/src/app/welcome/test.ts index 9fc3b0748..30d17a191 100644 --- a/tests/e2e/src/app/welcome/test.ts +++ b/tests/e2e/src/app/welcome/test.ts @@ -61,6 +61,9 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { await expect( page.getByRole("textbox", { name: /^(用户名|Username)\b/i }), ).toBeVisible(); + await expect( + page.getByLabel(/上传自己的头像|Upload your own avatar/i), + ).toBeVisible(); // user.image / user.profilePictures[] — avatar area should be visible const avatarArea = page @@ -99,6 +102,50 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { } }); +test("/account/welcome 上传头像后保存处理后的 R2 图片", async ({ page }) => { + test.setTimeout(300_000); + await signInAsDebugUser(page, "/"); + const sessionUser = await getCurrentSessionUser(page); + const originalUser = await getUserProfileById(sessionUser.id); + await updateUserProfileById(sessionUser.id, { name: null, username: null }); + + try { + await gotoAndWaitForReady(page, "/account/welcome"); + await page + .getByLabel(/上传自己的头像|Upload your own avatar/i) + .setInputFiles({ + name: "avatar.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6n0sAAAAASUVORK5CYII=", + "base64", + ), + }); + await page + .getByRole("textbox", { name: /^(姓名|Name)\b/i }) + .fill(DEV_SEED.debugName); + await page + .getByRole("textbox", { name: /^(用户名|Username)\b/i }) + .fill(DEV_SEED.debugUsername); + await page.getByRole("button", { name: /继续|Continue/i }).click(); + await expect(page).toHaveURL(/\/workspace\/overview(?:\?.*)?$/); + + const updatedUser = await getUserProfileById(sessionUser.id); + expect(updatedUser.image).toMatch( + new RegExp(`^/media/avatars/${sessionUser.id}/[0-9a-f-]{36}\\.webp$`), + ); + const avatarResponse = await page.request.get(updatedUser.image ?? ""); + expect(avatarResponse.status()).toBe(200); + expect(avatarResponse.headers()["content-type"]).toBe("image/webp"); + } finally { + await updateUserProfileById(sessionUser.id, { + name: originalUser.name ?? DEV_SEED.debugName, + username: originalUser.username ?? DEV_SEED.debugUsername, + image: originalUser.image ?? null, + }); + } +}); + test("资料不完整的登录用户从普通页面重定向到 /welcome", async ({ page }) => { test.setTimeout(300_000); await signInAsDebugUser(page, "/"); @@ -251,6 +298,15 @@ test("/account/welcome 提供浏览班级与批量匹配入口", async ({ name: /批量添加订阅|Bulk Add Subscriptions/i, }), ).toBeVisible(); + await expect( + page.getByText(/订阅与工作区|Subscriptions and workspace/i), + ).toBeVisible(); + await expect( + page.getByText(/日历与待办|Calendar and todos/i), + ).toBeVisible(); + await expect( + page.getByText(/账户与安全|Account and security/i), + ).toBeVisible(); await captureStepScreenshot(page, testInfo, "welcome/next-steps"); } finally { diff --git a/tests/unit/oauth-profile.test.ts b/tests/unit/oauth-profile.test.ts index c1baa59e2..dc13f99a1 100644 --- a/tests/unit/oauth-profile.test.ts +++ b/tests/unit/oauth-profile.test.ts @@ -30,6 +30,14 @@ describe("OAuth 档案映射", () => { image: undefined, emailVerified: false, }); + expect(consumeStagedSocialVerifiedEmail("oidc", "435")).toEqual({ + provider: "oidc", + accountId: "435", + email: null, + emailVerified: false, + name: "USTC User 435", + image: null, + }); }); it("忽略 passport fake_email 占位邮箱并回退到本地邮箱", () => { @@ -116,7 +124,14 @@ describe("OAuth 档案映射", () => { image: undefined, emailVerified: false, }); - expect(consumeStagedSocialVerifiedEmail("github", "octocat")).toBeNull(); + expect(consumeStagedSocialVerifiedEmail("github", "octocat")).toEqual({ + provider: "github", + accountId: "octocat", + email: null, + emailVerified: false, + name: "octocat", + image: null, + }); }); it("仅在邮箱已验证时暂存 Google 邮箱", () => { @@ -150,8 +165,13 @@ describe("OAuth 档案映射", () => { email_verified: false, }).emailVerified, ).toBe(false); - expect( - consumeStagedSocialVerifiedEmail("google", "google-user"), - ).toBeNull(); + expect(consumeStagedSocialVerifiedEmail("google", "google-user")).toEqual({ + provider: "google", + accountId: "google-user", + email: null, + emailVerified: false, + name: null, + image: null, + }); }); }); diff --git a/tests/unit/profile-avatar-service.test.ts b/tests/unit/profile-avatar-service.test.ts new file mode 100644 index 000000000..66d23fa8c --- /dev/null +++ b/tests/unit/profile-avatar-service.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + deleteStorageObjectMock, + getImagesBindingMock, + getStorageObjectResponseMock, + putStorageObjectMock, + userFindUniqueMock, +} = vi.hoisted(() => ({ + deleteStorageObjectMock: vi.fn(), + getImagesBindingMock: vi.fn(), + getStorageObjectResponseMock: vi.fn(), + putStorageObjectMock: vi.fn(), + userFindUniqueMock: vi.fn(), +})); + +vi.mock("@/lib/adapters/cloudflare-runtime", () => ({ + getCloudflareImagesBinding: getImagesBindingMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + user: { + findUnique: userFindUniqueMock, + }, + }, +})); + +vi.mock("@/lib/storage/r2-object", () => ({ + deleteStorageObject: deleteStorageObjectMock, + getStorageObjectResponse: getStorageObjectResponseMock, + putStorageObject: putStorageObjectMock, +})); + +import { + getPublicProfileAvatar, + processProfileAvatarUpload, + ProfileAvatarUploadError, + PROFILE_AVATAR_MAX_BYTES, +} from "@/features/profile/server/profile-avatar-service"; + +describe("profile avatar service", () => { + beforeEach(() => { + deleteStorageObjectMock.mockReset(); + getImagesBindingMock.mockReset(); + getStorageObjectResponseMock.mockReset(); + putStorageObjectMock.mockReset(); + userFindUniqueMock.mockReset(); + vi.restoreAllMocks(); + }); + + it("auto-crops an uploaded image and stores a 256px WebP in R2", async () => { + const response = new Response("transformed", { + headers: { "Content-Type": "image/webp" }, + }); + const output = vi.fn().mockResolvedValue({ + response: () => response, + }); + const transform = vi.fn().mockReturnValue({ output }); + const input = vi.fn().mockReturnValue({ transform }); + getImagesBindingMock.mockReturnValue({ input }); + vi.spyOn(crypto, "randomUUID").mockReturnValue( + "123e4567-e89b-12d3-a456-426614174000", + ); + + const result = await processProfileAvatarUpload({ + file: new File(["avatar"], "avatar.png", { type: "image/png" }), + userId: "user-1", + }); + + expect(transform).toHaveBeenCalledWith({ + width: 256, + height: 256, + fit: "cover", + gravity: "auto", + }); + expect(output).toHaveBeenCalledWith({ + format: "image/webp", + quality: 85, + }); + expect(putStorageObjectMock).toHaveBeenCalledWith({ + body: response.body, + contentType: "image/webp", + key: "avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + }); + expect(result).toEqual({ + key: "avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + url: "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + }); + }); + + it.each([ + [new File([], "empty.png", { type: "image/png" }), "empty"], + [new File(["text"], "avatar.txt", { type: "text/plain" }), "invalid_image"], + [ + new File([new Uint8Array(PROFILE_AVATAR_MAX_BYTES + 1)], "large.png", { + type: "image/png", + }), + "too_large", + ], + ] as const)("rejects invalid uploads", async (file, reason) => { + await expect( + processProfileAvatarUpload({ file, userId: "user-1" }), + ).rejects.toMatchObject({ reason }); + expect(putStorageObjectMock).not.toHaveBeenCalled(); + }); + + it("serves only avatar objects referenced by the owning profile", async () => { + const storedResponse = new Response("avatar"); + userFindUniqueMock.mockResolvedValue({ + image: "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + profilePictures: [], + }); + getStorageObjectResponseMock.mockResolvedValue(storedResponse); + + const response = await getPublicProfileAvatar({ + avatarId: "123e4567-e89b-12d3-a456-426614174000", + userId: "user-1", + }); + + expect(response?.headers.get("Cache-Control")).toBe( + "public, max-age=31536000, immutable", + ); + expect(getStorageObjectResponseMock).toHaveBeenCalledWith({ + contentDisposition: 'inline; filename="avatar.webp"', + contentType: "image/webp", + key: "avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + }); + + userFindUniqueMock.mockResolvedValue({ + image: "https://example.com/upstream.png", + profilePictures: [], + }); + await expect( + getPublicProfileAvatar({ + avatarId: "123e4567-e89b-12d3-a456-426614174000", + userId: "user-1", + }), + ).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/profile-update-service.test.ts b/tests/unit/profile-update-service.test.ts index 28a3ccb3b..9149b6d0a 100644 --- a/tests/unit/profile-update-service.test.ts +++ b/tests/unit/profile-update-service.test.ts @@ -42,6 +42,106 @@ describe("updateOwnProfile", () => { vi.resetModules(); }); + it.each([ + [{ ...profileInput, name: "" }, "name_required"], + [{ ...profileInput, username: "Invalid Name" }, "invalid_username"], + ] as const)("rejects invalid profile fields", async (input, reason) => { + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect(updateOwnProfile(input)).resolves.toEqual({ + ok: false, + reason, + }); + expect(prismaMock.user.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a missing user", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect(updateOwnProfile(profileInput)).resolves.toEqual({ + ok: false, + reason: "user_not_found", + }); + }); + + it("rejects an avatar that is neither upstream nor server-processed", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: ["https://example.test/allowed.webp"], + }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect( + updateOwnProfile({ + ...profileInput, + image: "https://attacker.example/avatar.webp", + }), + ).resolves.toEqual({ + ok: false, + reason: "avatar_invalid", + }); + }); + + it("rejects a username owned by another user", async () => { + prismaMock.user.findUnique + .mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: [], + }) + .mockResolvedValueOnce({ id: "user-2" }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect(updateOwnProfile(profileInput)).resolves.toEqual({ + ok: false, + reason: "username_taken", + }); + }); + + it("accepts a server-processed avatar and returns refreshed auth headers", async () => { + const headers = new Headers({ "set-cookie": "session=updated" }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: [], + }) + .mockResolvedValueOnce(null); + authApiMock.updateUser.mockResolvedValueOnce({ headers }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + const image = + "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp"; + + await expect( + updateOwnProfile({ + ...profileInput, + image, + trustedImageUrl: image, + }), + ).resolves.toEqual({ headers, ok: true }); + expect(authApiMock.updateUser).toHaveBeenCalledWith({ + body: { + image, + name: profileInput.name, + username: profileInput.username, + }, + headers: profileInput.headers, + returnHeaders: true, + }); + }); + it("将用户名唯一性竞争映射为 username_taken", async () => { const uniqueConflict = new Error("unique conflict"); isPrismaUniqueConstraintErrorMock.mockReturnValueOnce(true); diff --git a/tests/unit/social-verified-email-sync.test.ts b/tests/unit/social-verified-email-sync.test.ts index 6e1c56fe6..fd9478bd4 100644 --- a/tests/unit/social-verified-email-sync.test.ts +++ b/tests/unit/social-verified-email-sync.test.ts @@ -44,6 +44,7 @@ describe("social verified email sync", () => { email: "oidc-1@users.local", name: "USTC User 1", image: null, + profilePictures: [], }); userUpdateMock.mockResolvedValue(undefined); @@ -75,18 +76,45 @@ describe("social verified email sync", () => { email: "octocat@example.com", emailVerified: true, image: "https://example.com/octocat.png", + profilePictures: { push: "https://example.com/octocat.png" }, }, }); }); - it("skips non-social providers and missing staged emails", async () => { + it("syncs OIDC profile images without publishing a provider email", async () => { + stageSocialVerifiedEmail({ + provider: "oidc", + accountId: "435", + email: null, + emailVerified: false, + name: "Student", + image: "https://example.com/ustc.png", + }); + userFindUniqueMock.mockResolvedValue({ + email: "oidc-435@users.local", + name: "", + image: null, + profilePictures: [], + }); + userUpdateMock.mockResolvedValue(undefined); + await syncSocialVerifiedEmailFromAccountHook({ providerId: "oidc", providerAccountId: "435", userId: "user-1", }); expect(verifiedEmailUpsertMock).not.toHaveBeenCalled(); + expect(userUpdateMock).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + name: "Student", + image: "https://example.com/ustc.png", + profilePictures: { push: "https://example.com/ustc.png" }, + }, + }); + }); + it("skips providers without staged profile data", async () => { await syncSocialVerifiedEmailFromAccountHook({ providerId: "google", providerAccountId: "google-user", diff --git a/tests/unit/welcome-complete-action.test.ts b/tests/unit/welcome-complete-action.test.ts new file mode 100644 index 000000000..ce440206b --- /dev/null +++ b/tests/unit/welcome-complete-action.test.ts @@ -0,0 +1,153 @@ +import type { Cookies } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + applyAuthResponseCookiesMock, + deleteProcessedProfileAvatarMock, + getSessionFromHeadersMock, + processProfileAvatarUploadMock, + updateOwnProfileMock, +} = vi.hoisted(() => ({ + applyAuthResponseCookiesMock: vi.fn(), + deleteProcessedProfileAvatarMock: vi.fn(), + getSessionFromHeadersMock: vi.fn(), + processProfileAvatarUploadMock: vi.fn(), + updateOwnProfileMock: vi.fn(), +})); + +vi.mock("@/features/profile/server/profile-avatar-service", async () => { + const actual = await vi.importActual< + typeof import("@/features/profile/server/profile-avatar-service") + >("@/features/profile/server/profile-avatar-service"); + return { + ...actual, + deleteProcessedProfileAvatar: deleteProcessedProfileAvatarMock, + processProfileAvatarUpload: processProfileAvatarUploadMock, + }; +}); + +vi.mock("@/features/profile/server/profile-update-service", () => ({ + updateOwnProfile: updateOwnProfileMock, +})); + +vi.mock("@/lib/auth/core", () => ({ + getSessionFromHeaders: getSessionFromHeadersMock, +})); + +vi.mock("@/lib/auth/svelte-auth-actions", () => ({ + applyAuthResponseCookies: applyAuthResponseCookiesMock, +})); + +import { ProfileAvatarUploadError } from "@/features/profile/server/profile-avatar-service"; +import { completeWelcomeProfile } from "@/features/welcome/server/welcome-complete-action"; + +const cookies = {} as Cookies; +const locals = { + authUser: null, + locale: "en-us" as const, + publicSsr: false, + requestId: "request-1", +}; + +function requestWithForm(entries: Record) { + const form = new FormData(); + for (const [key, value] of Object.entries(entries)) form.set(key, value); + return new Request("https://life.example/account/welcome?/complete", { + body: form, + method: "POST", + }); +} + +describe("completeWelcomeProfile", () => { + beforeEach(() => { + applyAuthResponseCookiesMock.mockReset(); + deleteProcessedProfileAvatarMock.mockReset(); + deleteProcessedProfileAvatarMock.mockResolvedValue(undefined); + getSessionFromHeadersMock.mockReset(); + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + processProfileAvatarUploadMock.mockReset(); + updateOwnProfileMock.mockReset(); + }); + + it("passes a processed avatar through the trusted profile update boundary", async () => { + const headers = new Headers(); + processProfileAvatarUploadMock.mockResolvedValue({ + key: "avatars/user-1/avatar.webp", + url: "/media/avatars/user-1/avatar.webp", + }); + updateOwnProfileMock.mockResolvedValue({ headers, ok: true }); + + await expect( + completeWelcomeProfile({ + cookies, + locals, + request: requestWithForm({ + avatar: new File(["image"], "avatar.png", { type: "image/png" }), + callbackUrl: "/workspace/overview", + name: "Test User", + username: "test-user", + }), + }), + ).rejects.toMatchObject({ + location: "/workspace/overview", + status: 303, + }); + expect(updateOwnProfileMock).toHaveBeenCalledWith({ + headers: expect.any(Headers), + image: "/media/avatars/user-1/avatar.webp", + name: "Test User", + trustedImageUrl: "/media/avatars/user-1/avatar.webp", + userId: "user-1", + username: "test-user", + }); + expect(applyAuthResponseCookiesMock).toHaveBeenCalledWith(headers, cookies); + }); + + it("removes the processed object when profile validation fails", async () => { + processProfileAvatarUploadMock.mockResolvedValue({ + key: "avatars/user-1/avatar.webp", + url: "/media/avatars/user-1/avatar.webp", + }); + updateOwnProfileMock.mockResolvedValue({ + ok: false, + reason: "username_taken", + }); + + const result = await completeWelcomeProfile({ + cookies, + locals, + request: requestWithForm({ + avatar: new File(["image"], "avatar.png", { type: "image/png" }), + name: "Test User", + username: "taken", + }), + }); + + expect(result.status).toBe(400); + expect(deleteProcessedProfileAvatarMock).toHaveBeenCalledWith( + "avatars/user-1/avatar.webp", + ); + }); + + it("maps image processing failures to a user-visible message", async () => { + processProfileAvatarUploadMock.mockRejectedValue( + new ProfileAvatarUploadError("too_large"), + ); + + const result = await completeWelcomeProfile({ + cookies, + locals, + request: requestWithForm({ + avatar: new File(["image"], "avatar.png", { type: "image/png" }), + name: "Test User", + username: "test-user", + }), + }); + + expect(result.status).toBe(400); + expect(result.data).toEqual({ + message: "Avatar files must be 5 MB or smaller.", + }); + expect(updateOwnProfileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/welcome-oauth-refresh-action.test.ts b/tests/unit/welcome-oauth-refresh-action.test.ts new file mode 100644 index 000000000..ea849b4ae --- /dev/null +++ b/tests/unit/welcome-oauth-refresh-action.test.ts @@ -0,0 +1,109 @@ +import type { Cookies } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + accountFindFirstMock, + getSessionFromHeadersMock, + linkAccountFromSvelteActionMock, + logServerActionErrorMock, +} = vi.hoisted(() => ({ + accountFindFirstMock: vi.fn(), + getSessionFromHeadersMock: vi.fn(), + linkAccountFromSvelteActionMock: vi.fn(), + logServerActionErrorMock: vi.fn(), +})); + +vi.mock("@/lib/auth/core", () => ({ + getSessionFromHeaders: getSessionFromHeadersMock, +})); + +vi.mock("@/lib/auth/svelte-auth-actions", () => ({ + linkAccountFromSvelteAction: linkAccountFromSvelteActionMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + account: { + findFirst: accountFindFirstMock, + }, + }, +})); + +vi.mock("@/lib/log/app-logger", () => ({ + logServerActionError: logServerActionErrorMock, +})); + +import { refreshWelcomeOAuthProfile } from "@/features/welcome/server/welcome-oauth-refresh-action"; + +const cookies = {} as Cookies; +const locals = { + authUser: null, + locale: "en-us" as const, + publicSsr: false, + requestId: "request-1", +}; + +function request(providerId: string) { + return new Request("https://life.example/account/welcome?/refreshOAuth", { + body: new URLSearchParams({ + callbackUrl: "/account/settings", + providerId, + }), + method: "POST", + }); +} + +describe("refreshWelcomeOAuthProfile", () => { + beforeEach(() => { + accountFindFirstMock.mockReset(); + getSessionFromHeadersMock.mockReset(); + linkAccountFromSvelteActionMock.mockReset(); + logServerActionErrorMock.mockReset(); + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("reauthorizes only a provider linked to the current user", async () => { + accountFindFirstMock.mockResolvedValue({ id: "account-1" }); + linkAccountFromSvelteActionMock.mockResolvedValue({ + url: "https://provider.example/authorize", + }); + + await expect( + refreshWelcomeOAuthProfile({ + cookies, + locals, + request: request("github"), + }), + ).rejects.toMatchObject({ + location: "https://provider.example/authorize", + status: 303, + }); + expect(accountFindFirstMock).toHaveBeenCalledWith({ + where: { userId: "user-1", provider: "github" }, + select: { id: true }, + }); + expect(linkAccountFromSvelteActionMock).toHaveBeenCalledWith({ + providerId: "github", + callbackUrl: + "/account/welcome?callbackUrl=%2Faccount%2Fsettings&oauthRefreshed=1", + headers: expect.any(Headers), + cookies, + }); + }); + + it("rejects a provider that is not linked to the current user", async () => { + accountFindFirstMock.mockResolvedValue(null); + + const result = await refreshWelcomeOAuthProfile({ + cookies, + locals, + request: request("google"), + }); + + expect(result.status).toBe(400); + expect(result.data).toEqual({ + message: "That sign-in account is not linked to the current user.", + }); + expect(linkAccountFromSvelteActionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/welcome-redirect.test.ts b/tests/unit/welcome-redirect.test.ts index f457684ac..8fa5f2eaa 100644 --- a/tests/unit/welcome-redirect.test.ts +++ b/tests/unit/welcome-redirect.test.ts @@ -24,6 +24,7 @@ describe("欢迎页重定向策略", () => { it("不重定向 API、发现服务或静态资源请求", () => { expect(shouldRedirect("/api/account/profile")).toBe(false); + expect(shouldRedirect("/media/avatars/user-1/avatar.webp")).toBe(false); expect(shouldRedirect("/.well-known/openid-configuration")).toBe(false); expect(shouldRedirect("/_app/immutable/start.js")).toBe(false); expect(shouldRedirect("/llms.txt")).toBe(false); From 8a50b394fad267bf0c08d89eb1d1e8b70199a70f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 15:17:40 +0000 Subject: [PATCH 3/8] fix(onboarding): align generated types and lint rules Co-authored-by: Tiankai Ma --- src/features/profile/server/profile-avatar-service.ts | 2 +- src/features/welcome/components/WelcomeProfileForm.svelte | 2 +- src/features/welcome/server/welcome-complete-action.ts | 2 +- src/features/welcome/server/welcome-page-server.ts | 2 +- tests/unit/profile-avatar-service.test.ts | 5 ++--- worker-configuration.d.ts | 3 ++- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/features/profile/server/profile-avatar-service.ts b/src/features/profile/server/profile-avatar-service.ts index 0a60f3942..ff8bdc8dc 100644 --- a/src/features/profile/server/profile-avatar-service.ts +++ b/src/features/profile/server/profile-avatar-service.ts @@ -1,10 +1,10 @@ +import { getCloudflareImagesBinding } from "@/lib/adapters/cloudflare-runtime"; import { prisma } from "@/lib/db/prisma"; import { deleteStorageObject, getStorageObjectResponse, putStorageObject, } from "@/lib/storage/r2-object"; -import { getCloudflareImagesBinding } from "@/lib/adapters/cloudflare-runtime"; export const PROFILE_AVATAR_DIMENSION = 256; export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024; diff --git a/src/features/welcome/components/WelcomeProfileForm.svelte b/src/features/welcome/components/WelcomeProfileForm.svelte index 5b4e3150d..27486897d 100644 --- a/src/features/welcome/components/WelcomeProfileForm.svelte +++ b/src/features/welcome/components/WelcomeProfileForm.svelte @@ -1,10 +1,10 @@ + + + + {welcomeCopy.finishTitle} + {welcomeCopy.finishDescription} + + + {#each guides as guide (guide.title)} +
+ + + +
+

{guide.title}

+

{guide.description}

+
+
+ {/each} +
+ + {#if backUrl} + + {/if} + + +
diff --git a/src/features/welcome/components/WelcomeNextStepsCard.svelte b/src/features/welcome/components/WelcomeNextStepsCard.svelte index 94eb99df4..0ad5a667d 100644 --- a/src/features/welcome/components/WelcomeNextStepsCard.svelte +++ b/src/features/welcome/components/WelcomeNextStepsCard.svelte @@ -1,20 +1,22 @@ - + {welcomeCopy.nextStepsTitle} - - {welcomeCopy.nextStepsDescription} - + {welcomeCopy.nextStepsDescription} {#if importMessage} @@ -22,22 +24,20 @@ export let welcomeCopy: WelcomeCopy; {importMessage} {/if} + - -
-
-

{welcomeCopy.guideWorkspaceTitle}

-

{welcomeCopy.guideWorkspaceDescription}

-
-
-

{welcomeCopy.guidePlanningTitle}

-

{welcomeCopy.guidePlanningDescription}

-
-
-

{welcomeCopy.guideAccountTitle}

-

{welcomeCopy.guideAccountDescription}

-
-
+ + {#if backUrl} + + {/if} + +
diff --git a/src/features/welcome/components/WelcomePageController.svelte b/src/features/welcome/components/WelcomePageController.svelte index 831290ac1..d626fb857 100644 --- a/src/features/welcome/components/WelcomePageController.svelte +++ b/src/features/welcome/components/WelcomePageController.svelte @@ -1,8 +1,10 @@ + + diff --git a/src/features/welcome/components/welcome-component-types.ts b/src/features/welcome/components/welcome-component-types.ts index 2d4fa328a..c53346f36 100644 --- a/src/features/welcome/components/welcome-component-types.ts +++ b/src/features/welcome/components/welcome-component-types.ts @@ -1,5 +1,13 @@ import type { SubmitFunction } from "@sveltejs/kit"; import type { WelcomeMatchedSection } from "@/features/welcome/lib/welcome-bulk-import-types"; +import type { WelcomeStep } from "@/features/welcome/lib/welcome-steps"; + +export type WelcomeStepIndicator = { + id: WelcomeStep; + label: string; + number: number; + state: "complete" | "current" | "upcoming"; +}; export type WelcomeProfileCopy = { avatarUpload: string; @@ -14,9 +22,18 @@ export type WelcomeProfileCopy = { }; export type WelcomeCopy = Record & { + back: string; browseCourses: string; browseSections: string; bulkImportCta: string; + finishDescription: string; + finishTitle: string; + skipForNow: string; + startUsing: string; + stepFinish: string; + stepProfile: string; + stepProgress: string; + stepSubscriptions: string; confirmImportTitle: string; avatarLater: string; continue: string; @@ -85,13 +102,17 @@ export type WelcomePageUser = WelcomeProfileUser & { }; export type WelcomePageData = { + backUrl: string | null; callbackUrl: string; copy: WelcomePageCopy; defaultSemesterId?: number | string | null; locale: string; + nextUrl: string; oauthProviders: Array<{ id: string; name: string }>; oauthRefreshed: boolean; semesters: WelcomeSemester[]; + step: WelcomeStep; + stepIndicators: WelcomeStepIndicator[]; user: WelcomePageUser; }; diff --git a/src/features/welcome/lib/welcome-steps.ts b/src/features/welcome/lib/welcome-steps.ts new file mode 100644 index 000000000..eee04f4a0 --- /dev/null +++ b/src/features/welcome/lib/welcome-steps.ts @@ -0,0 +1,25 @@ +export const WELCOME_STEPS = ["profile", "subscriptions", "finish"] as const; + +export type WelcomeStep = (typeof WELCOME_STEPS)[number]; + +export function parseWelcomeStep(value: unknown): WelcomeStep { + return WELCOME_STEPS.includes(value as WelcomeStep) + ? (value as WelcomeStep) + : "profile"; +} + +export function welcomeStepNumber(step: WelcomeStep) { + return WELCOME_STEPS.indexOf(step) + 1; +} + +export function nextWelcomeStep(step: WelcomeStep): WelcomeStep | null { + return WELCOME_STEPS[welcomeStepNumber(step)] ?? null; +} + +export function previousWelcomeStep(step: WelcomeStep): WelcomeStep | null { + return WELCOME_STEPS[welcomeStepNumber(step) - 2] ?? null; +} + +export function buildWelcomeStepUrl(step: WelcomeStep, callbackUrl: string) { + return `/account/welcome?step=${step}&callbackUrl=${encodeURIComponent(callbackUrl)}`; +} diff --git a/src/features/welcome/server/welcome-complete-action.ts b/src/features/welcome/server/welcome-complete-action.ts index 646efbe40..7fb8eea0f 100644 --- a/src/features/welcome/server/welcome-complete-action.ts +++ b/src/features/welcome/server/welcome-complete-action.ts @@ -6,6 +6,7 @@ import { processProfileAvatarUpload, } from "@/features/profile/server/profile-avatar-service"; import { updateOwnProfile } from "@/features/profile/server/profile-update-service"; +import { buildWelcomeStepUrl } from "@/features/welcome/lib/welcome-steps"; import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; import { getSessionFromHeaders } from "@/lib/auth/core"; import { applyAuthResponseCookies } from "@/lib/auth/svelte-auth-actions"; @@ -104,5 +105,5 @@ export async function completeWelcomeProfile({ } applyAuthResponseCookies(result.headers, cookies); - throw redirect(303, redirectTo); + throw redirect(303, buildWelcomeStepUrl("subscriptions", redirectTo)); } diff --git a/src/features/welcome/server/welcome-page-server.ts b/src/features/welcome/server/welcome-page-server.ts index 84cf8dc51..b15cee348 100644 --- a/src/features/welcome/server/welcome-page-server.ts +++ b/src/features/welcome/server/welcome-page-server.ts @@ -1,6 +1,15 @@ import { redirect, type ServerLoadEvent } from "@sveltejs/kit"; import { providerNames } from "@/features/auth/server/signin-page-copy"; import { getCurrentSemester } from "@/features/catalog/server/academic-metadata-read-model"; +import { + buildWelcomeStepUrl, + nextWelcomeStep, + parseWelcomeStep, + previousWelcomeStep, + WELCOME_STEPS, + type WelcomeStep, + welcomeStepNumber, +} from "@/features/welcome/lib/welcome-steps"; import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; import { getSessionFromHeaders } from "@/lib/auth/core"; import { prisma } from "@/lib/db/prisma"; @@ -11,6 +20,30 @@ import { getWelcomeCopy } from "./welcome-page-copy"; const REFRESHABLE_PROVIDERS = new Set(["github", "google", "oidc"]); +const STEP_TITLE_KEYS = { + profile: "stepProfile", + subscriptions: "stepSubscriptions", + finish: "stepFinish", +} as const satisfies Record; + +function buildStepIndicators( + step: WelcomeStep, + copy: ReturnType, +) { + const currentNumber = welcomeStepNumber(step); + return WELCOME_STEPS.map((id) => ({ + id, + label: copy.welcome[STEP_TITLE_KEYS[id]], + number: welcomeStepNumber(id), + state: + id === step + ? ("current" as const) + : welcomeStepNumber(id) < currentNumber + ? ("complete" as const) + : ("upcoming" as const), + })); +} + export const loadWelcomePage = async ({ locals, request, @@ -60,11 +93,29 @@ export const loadWelcomePage = async ({ ); } - if (user.name && user.username) { + // Profile is the only required step, so an incomplete profile always returns + // there and a complete profile never lands back on it. + const hasCompleteProfile = Boolean(user.name && user.username); + const step = hasCompleteProfile + ? parseWelcomeStep(url.searchParams.get("step")) + : "profile"; + if (hasCompleteProfile && step === "profile") { throw redirect(303, callbackUrl); } + const copy = getWelcomeCopy(locals.locale); + const previousStep = previousWelcomeStep(step); + const followingStep = nextWelcomeStep(step); + return { + step, + stepIndicators: buildStepIndicators(step, copy), + backUrl: previousStep + ? buildWelcomeStepUrl(previousStep, callbackUrl) + : null, + nextUrl: followingStep + ? buildWelcomeStepUrl(followingStep, callbackUrl) + : callbackUrl, user: { id: user.id, name: user.name, @@ -90,7 +141,7 @@ export const loadWelcomePage = async ({ defaultSemesterId: currentSemester?.id ?? null, callbackUrl, locale: locals.locale, - copy: getWelcomeCopy(locals.locale), + copy, }; }; From 58b01d52b7f8a1722da610efb1ccf57135cb3a27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 16:03:47 +0000 Subject: [PATCH 8/8] test(onboarding): cover staged welcome navigation Co-authored-by: Tiankai Ma --- tests/e2e/src/app/_shared/page-inventory.ts | 18 +++ tests/e2e/src/app/welcome/test.ts | 159 +++++++++++--------- tests/e2e/utils/auth.ts | 8 + tests/unit/welcome-complete-action.test.ts | 3 +- tests/unit/welcome-page-server.test.ts | 140 +++++++++++++++++ tests/unit/welcome-steps.test.ts | 36 +++++ 6 files changed, 296 insertions(+), 68 deletions(-) create mode 100644 tests/unit/welcome-page-server.test.ts create mode 100644 tests/unit/welcome-steps.test.ts diff --git a/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts index d197a0e7c..c8d00ab28 100644 --- a/tests/e2e/src/app/_shared/page-inventory.ts +++ b/tests/e2e/src/app/_shared/page-inventory.ts @@ -251,6 +251,24 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ name: "/批量添加订阅|Bulk Add Subscriptions/i", e2eSpec: E2E.welcome, }, + { + id: "welcome-skip-step", + role: "link", + name: "/暂时跳过|Skip for now/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-finish", + role: "link", + name: "/进入工作区|Go to workspace/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-back-step", + role: "link", + name: "/上一步|Back/i", + e2eSpec: E2E.welcome, + }, ], }, { diff --git a/tests/e2e/src/app/welcome/test.ts b/tests/e2e/src/app/welcome/test.ts index 6227009b9..f5dfa6968 100644 --- a/tests/e2e/src/app/welcome/test.ts +++ b/tests/e2e/src/app/welcome/test.ts @@ -11,13 +11,15 @@ * * ## Features * - Unauthenticated → redirect to /signin - * - Users with no name/username must complete before proceeding to / - * - Avatar selector grid is shown - * - Semester dropdown pre-selects the current semester - * - Links to browse sections / courses and bulk import + * - Staged flow: required profile step, then optional subscriptions and + * orientation steps, each with a progress indicator + * - Users with no name/username must complete the profile step first + * - Avatar selector grid and custom avatar upload are shown + * - Semester dropdown pre-selects the current semester (subscriptions step) * * ## Edge Cases - * - After successful save redirects to / + * - A complete profile requesting the profile step leaves onboarding + * - The final step returns to the original callbackUrl * - Name and username fields are restored to seed values after test */ import { expect, test } from "@playwright/test"; @@ -40,7 +42,9 @@ test("/account/welcome 未登录重定向到登录页", async ({ page }, testInf await captureStepScreenshot(page, testInfo, "welcome/unauthorized"); }); -test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { +test("/account/welcome 资料步骤显示必填字段与进度", async ({ + page, +}, testInfo) => { test.setTimeout(300_000); await signInAsDebugUser(page, "/"); const sessionUser = await getCurrentSessionUser(page); @@ -71,26 +75,13 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { .first(); await expect(avatarArea).toBeVisible(); - // semesters[] dropdown options (defaultSemesterId preselected) - // The semester selector is inside the Bulk Import dialog - const bulkImportBtn = page.getByRole("button", { - name: /批量添加订阅|Bulk Add Subscriptions/i, - }); - await expect(bulkImportBtn).toBeVisible(); - await bulkImportBtn.click(); - // Dialog opens, semester selector inside - const dialog = page - .getByRole("dialog") - .or(page.getByRole("alertdialog")) - .first(); - await expect(dialog).toBeVisible({ timeout: 8_000 }); - const semesterSelector = dialog - .getByRole("combobox", { name: /^(学期|Semester)\b/i }) - .first(); - await expect(semesterSelector).toBeVisible(); - await expect(semesterSelector).toContainText(DEV_SEED.semesterNameCn); - // Close dialog - await page.keyboard.press("Escape"); + // Only the current step is rendered, so later steps stay out of the way. + await expect(page.getByText(/第 1 步|Step 1 of/i)).toBeVisible(); + await expect( + page.getByRole("button", { + name: /批量添加订阅|Bulk Add Subscriptions/i, + }), + ).toHaveCount(0); await captureStepScreenshot(page, testInfo, "welcome/fields"); } finally { @@ -207,6 +198,18 @@ test("/account/welcome 完成后返回原回调页面", async ({ page }, testInf await page.getByRole("button", { name: /继续|Continue/i }).click(); + await expect(page).toHaveURL( + /\/account\/welcome\?step=subscriptions&callbackUrl=%2Faccount%2Fsettings$/, + { timeout: 15_000 }, + ); + await page.getByRole("link", { name: /暂时跳过|Skip for now/i }).click(); + await expect(page).toHaveURL( + /\/account\/welcome\?step=finish&callbackUrl=%2Faccount%2Fsettings$/, + ); + await page + .getByRole("link", { name: /进入工作区|Go to workspace/i }) + .click(); + await expect(page).toHaveURL(/\/account\/settings\/profile(?:\?.*)?$/, { timeout: 15_000, }); @@ -251,6 +254,14 @@ test("/account/welcome 未完善资料的用户可完成资料并返回首页", await page.getByRole("button", { name: /继续|Continue/i }).click(); + await expect(page).toHaveURL(/step=subscriptions/, { timeout: 15_000 }); + await expect(page.getByText(/第 2 步|Step 2 of/i)).toBeVisible(); + await page.getByRole("link", { name: /暂时跳过|Skip for now/i }).click(); + await expect(page.getByText(/第 3 步|Step 3 of/i)).toBeVisible(); + await page + .getByRole("link", { name: /进入工作区|Go to workspace/i }) + .click(); + await expect(page).toHaveURL(/\/workspace\/overview(?:\?.*)?$/, { timeout: 15_000, }); @@ -269,55 +280,69 @@ test("/account/welcome 未完善资料的用户可完成资料并返回首页", } }); -test("/account/welcome 提供浏览班级与批量匹配入口", async ({ +test("/account/welcome 订阅步骤提供浏览班级与批量匹配入口", async ({ page, }, testInfo) => { test.setTimeout(300_000); await signInAsDebugUser(page, "/"); - const sessionUser = await getCurrentSessionUser(page); - const originalUser = await getUserProfileById(sessionUser.id); - - await updateUserProfileById(sessionUser.id, { - name: null, - username: null, + await gotoAndWaitForReady( + page, + "/account/welcome?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview", + { testInfo, screenshotLabel: "welcome-subscriptions" }, + ); + + await expect( + page.getByRole("link", { name: /浏览班级|Browse Sections/i }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: /浏览课程|Browse Courses/i }), + ).toBeVisible(); + + // semesters[] dropdown options (defaultSemesterId preselected) + const bulkImportBtn = page.getByRole("button", { + name: /批量添加订阅|Bulk Add Subscriptions/i, }); + await expect(bulkImportBtn).toBeVisible(); + await bulkImportBtn.click(); + const dialog = page + .getByRole("dialog") + .or(page.getByRole("alertdialog")) + .first(); + await expect(dialog).toBeVisible({ timeout: 8_000 }); + const semesterSelector = dialog + .getByRole("combobox", { name: /^(学期|Semester)\b/i }) + .first(); + await expect(semesterSelector).toBeVisible(); + await expect(semesterSelector).toContainText(DEV_SEED.semesterNameCn); + await page.keyboard.press("Escape"); + + await captureStepScreenshot(page, testInfo, "welcome/next-steps"); +}); - try { - await gotoAndWaitForReady(page, "/account/welcome", { - testInfo, - screenshotLabel: "welcome", - }); - - await expect( - page.getByRole("link", { name: /浏览班级|Browse Sections/i }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: /浏览课程|Browse Courses/i }), - ).toBeVisible(); - await expect( - page.getByRole("button", { - name: /批量添加订阅|Bulk Add Subscriptions/i, - }), - ).toBeVisible(); - await expect( - page.getByText(/订阅与工作区|Subscriptions and workspace/i), - ).toBeVisible(); - await expect( - page.getByText(/日历与待办|Calendar and todos/i), - ).toBeVisible(); - await expect( - page.getByText(/账户与安全|Account and security/i), - ).toBeVisible(); +test("/account/welcome 最后一步展示平台引导并可返回上一步", async ({ + page, +}, testInfo) => { + test.setTimeout(300_000); + await signInAsDebugUser(page, "/"); - await captureStepScreenshot(page, testInfo, "welcome/next-steps"); - } finally { - await updateUserProfileById(sessionUser.id, { - name: originalUser.name ?? DEV_SEED.debugName, - username: originalUser.username ?? DEV_SEED.debugUsername, - image: originalUser.image ?? null, - }); - } + await gotoAndWaitForReady( + page, + "/account/welcome?step=finish&callbackUrl=%2Fworkspace%2Foverview", + { testInfo, screenshotLabel: "welcome-finish" }, + ); + + await expect( + page.getByText(/订阅与工作区|Subscriptions and workspace/i), + ).toBeVisible(); + await expect(page.getByText(/日历与待办|Calendar and todos/i)).toBeVisible(); + await expect( + page.getByText(/账户与安全|Account and security/i), + ).toBeVisible(); + await captureStepScreenshot(page, testInfo, "welcome/finish"); + + await page.getByRole("link", { name: /上一步|Back/i }).click(); + await expect(page).toHaveURL(/step=subscriptions/); }); test("页面契约", async ({ page }, testInfo) => { diff --git a/tests/e2e/utils/auth.ts b/tests/e2e/utils/auth.ts index a406560dd..cfbc6bef3 100644 --- a/tests/e2e/utils/auth.ts +++ b/tests/e2e/utils/auth.ts @@ -109,6 +109,14 @@ async function completeWelcomeProfileIfNeeded( await nameInput.fill(DEV_SEED.debugName); await usernameInput.fill(DEV_SEED.debugUsername); await page.getByRole("button", { name: /继续|Continue/i }).click(); + // The profile step hands off to the optional onboarding steps, which the + // harness skips by navigating straight to the destination. + await page.waitForURL( + (url) => + !url.pathname.startsWith("/account/welcome") || + url.searchParams.get("step") !== null, + { timeout: 15_000 }, + ); await gotoAndWaitForReady(page, expectedPath); } diff --git a/tests/unit/welcome-complete-action.test.ts b/tests/unit/welcome-complete-action.test.ts index ce440206b..3e6ddb8ba 100644 --- a/tests/unit/welcome-complete-action.test.ts +++ b/tests/unit/welcome-complete-action.test.ts @@ -89,7 +89,8 @@ describe("completeWelcomeProfile", () => { }), }), ).rejects.toMatchObject({ - location: "/workspace/overview", + location: + "/account/welcome?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview", status: 303, }); expect(updateOwnProfileMock).toHaveBeenCalledWith({ diff --git a/tests/unit/welcome-page-server.test.ts b/tests/unit/welcome-page-server.test.ts new file mode 100644 index 000000000..4c3e3ff2d --- /dev/null +++ b/tests/unit/welcome-page-server.test.ts @@ -0,0 +1,140 @@ +import type { ServerLoadEvent } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getCurrentSemesterMock, + getSessionFromHeadersMock, + semesterFindManyMock, + userFindUniqueMock, +} = vi.hoisted(() => ({ + getCurrentSemesterMock: vi.fn(), + getSessionFromHeadersMock: vi.fn(), + semesterFindManyMock: vi.fn(), + userFindUniqueMock: vi.fn(), +})); + +vi.mock("@/features/catalog/server/academic-metadata-read-model", () => ({ + getCurrentSemester: getCurrentSemesterMock, +})); + +vi.mock("@/lib/auth/core", () => ({ + getSessionFromHeaders: getSessionFromHeadersMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + semester: { findMany: semesterFindManyMock }, + user: { findUnique: userFindUniqueMock }, + }, +})); + +import { loadWelcomePage } from "@/features/welcome/server/welcome-page-server"; + +function loadEvent(search: string) { + const url = new URL(`https://life.example/account/welcome${search}`); + return { + locals: { + authUser: null, + locale: "en-us" as const, + publicSsr: false, + requestId: "request-1", + }, + request: new Request(url), + url, + } as unknown as ServerLoadEvent; +} + +describe("loadWelcomePage", () => { + beforeEach(() => { + getCurrentSemesterMock.mockReset(); + getCurrentSemesterMock.mockResolvedValue({ id: 7 }); + getSessionFromHeadersMock.mockReset(); + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + semesterFindManyMock.mockReset(); + semesterFindManyMock.mockResolvedValue([{ id: 7, nameCn: "2026 秋" }]); + userFindUniqueMock.mockReset(); + }); + + it("keeps an incomplete profile on the required first step", async () => { + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + name: null, + username: null, + image: null, + profilePictures: [], + accounts: [{ provider: "github" }, { provider: "credential" }], + }); + + const data = await loadWelcomePage(loadEvent("?step=finish")); + + expect(data.step).toBe("profile"); + expect(data.backUrl).toBeNull(); + expect(data.nextUrl).toBe( + "/account/welcome?step=subscriptions&callbackUrl=%2F", + ); + expect(data.stepIndicators).toEqual([ + { id: "profile", label: "Your profile", number: 1, state: "current" }, + { + id: "subscriptions", + label: "Section subscriptions", + number: 2, + state: "upcoming", + }, + { id: "finish", label: "Get started", number: 3, state: "upcoming" }, + ]); + expect(data.oauthProviders).toEqual([{ id: "github", name: "GitHub" }]); + }); + + it("leaves onboarding when a complete profile requests the first step", async () => { + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + name: "Test User", + username: "test-user", + image: null, + profilePictures: [], + accounts: [], + }); + + await expect( + loadWelcomePage(loadEvent("?callbackUrl=%2Faccount%2Fsettings")), + ).rejects.toMatchObject({ + location: "/account/settings", + status: 303, + }); + }); + + it("advances a complete profile through the optional steps", async () => { + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + name: "Test User", + username: "test-user", + image: null, + profilePictures: [], + accounts: [], + }); + + const subscriptions = await loadWelcomePage( + loadEvent("?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview"), + ); + expect(subscriptions.step).toBe("subscriptions"); + expect(subscriptions.backUrl).toBe( + "/account/welcome?step=profile&callbackUrl=%2Fworkspace%2Foverview", + ); + expect(subscriptions.nextUrl).toBe( + "/account/welcome?step=finish&callbackUrl=%2Fworkspace%2Foverview", + ); + expect( + subscriptions.stepIndicators.map(({ id, state }) => [id, state]), + ).toEqual([ + ["profile", "complete"], + ["subscriptions", "current"], + ["finish", "upcoming"], + ]); + + const finish = await loadWelcomePage( + loadEvent("?step=finish&callbackUrl=%2Fworkspace%2Foverview"), + ); + expect(finish.step).toBe("finish"); + expect(finish.nextUrl).toBe("/workspace/overview"); + }); +}); diff --git a/tests/unit/welcome-steps.test.ts b/tests/unit/welcome-steps.test.ts new file mode 100644 index 000000000..6d67446d6 --- /dev/null +++ b/tests/unit/welcome-steps.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + buildWelcomeStepUrl, + nextWelcomeStep, + parseWelcomeStep, + previousWelcomeStep, + welcomeStepNumber, +} from "@/features/welcome/lib/welcome-steps"; + +describe("欢迎流程步骤", () => { + it("将未知步骤解析为必填的资料步骤", () => { + expect(parseWelcomeStep(null)).toBe("profile"); + expect(parseWelcomeStep("")).toBe("profile"); + expect(parseWelcomeStep("finish?")).toBe("profile"); + expect(parseWelcomeStep("subscriptions")).toBe("subscriptions"); + expect(parseWelcomeStep("finish")).toBe("finish"); + }); + + it("按顺序前进和后退", () => { + expect(welcomeStepNumber("profile")).toBe(1); + expect(welcomeStepNumber("finish")).toBe(3); + expect(nextWelcomeStep("profile")).toBe("subscriptions"); + expect(nextWelcomeStep("subscriptions")).toBe("finish"); + expect(nextWelcomeStep("finish")).toBeNull(); + expect(previousWelcomeStep("finish")).toBe("subscriptions"); + expect(previousWelcomeStep("profile")).toBeNull(); + }); + + it("构建步骤 URL 时编码回调地址", () => { + expect( + buildWelcomeStepUrl("subscriptions", "/account/settings?tab=a"), + ).toBe( + "/account/welcome?step=subscriptions&callbackUrl=%2Faccount%2Fsettings%3Ftab%3Da", + ); + }); +});