@@ -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/WelcomeStepper.svelte b/src/features/welcome/components/WelcomeStepper.svelte
new file mode 100644
index 000000000..c509b6910
--- /dev/null
+++ b/src/features/welcome/components/WelcomeStepper.svelte
@@ -0,0 +1,37 @@
+
+
+
diff --git a/src/features/welcome/components/welcome-component-types.ts b/src/features/welcome/components/welcome-component-types.ts
index 50ec94aaa..c53346f36 100644
--- a/src/features/welcome/components/welcome-component-types.ts
+++ b/src/features/welcome/components/welcome-component-types.ts
@@ -1,7 +1,17 @@
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;
+ avatarUploadHint: string;
name: string;
namePlaceholder: string;
profilePicture: string;
@@ -12,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;
@@ -83,11 +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 79c15ed73..7fb8eea0f 100644
--- a/src/features/welcome/server/welcome-complete-action.ts
+++ b/src/features/welcome/server/welcome-complete-action.ts
@@ -1,6 +1,12 @@
import type { Cookies } from "@sveltejs/kit";
import { fail, redirect } from "@sveltejs/kit";
+import {
+ deleteProcessedProfileAvatar,
+ ProfileAvatarUploadError,
+ 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";
@@ -8,6 +14,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 +33,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 +46,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 });
}
@@ -55,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-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..b15cee348 100644
--- a/src/features/welcome/server/welcome-page-server.ts
+++ b/src/features/welcome/server/welcome-page-server.ts
@@ -1,12 +1,49 @@
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";
import { resolveWelcomeCallbackUrl } from "./welcome-callback-url";
import { completeWelcomeProfile } from "./welcome-complete-action";
+import { refreshWelcomeOAuthProfile } from "./welcome-oauth-refresh-action";
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,
@@ -34,6 +71,9 @@ export const loadWelcomePage = async ({
username: true,
image: true,
profilePictures: true,
+ accounts: {
+ select: { provider: true },
+ },
},
}),
prisma.semester.findMany({
@@ -53,20 +93,59 @@ 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 {
- user,
+ step,
+ stepIndicators: buildStepIndicators(step, copy),
+ backUrl: previousStep
+ ? buildWelcomeStepUrl(previousStep, callbackUrl)
+ : null,
+ nextUrl: followingStep
+ ? buildWelcomeStepUrl(followingStep, callbackUrl)
+ : callbackUrl,
+ 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,
locale: locals.locale,
- copy: getWelcomeCopy(locals.locale),
+ copy,
};
};
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..9eea3d6f6 100644
--- a/src/lib/auth/social-verified-email-plugin.ts
+++ b/src/lib/auth/social-verified-email-plugin.ts
@@ -6,8 +6,10 @@ import {
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";
+import { prisma } from "@/lib/db/prisma";
+import { logAppEvent } from "@/lib/log/app-logger";
-const SOCIAL_VERIFIED_EMAIL_PROVIDERS = new Set(["github", "google"]);
+const SOCIAL_PROFILE_PROVIDERS = new Set(["github", "google", "oidc"]);
type AccountHookPayload = Pick<
Account,
@@ -16,14 +18,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;
@@ -34,7 +36,11 @@ async function applySocialVerifiedEmailToUser(input: {
image?: string | null;
} = {};
- if (isPlaceholderUserEmail(current.email)) {
+ if (
+ input.email &&
+ isPublishableUserEmail(input.email) &&
+ isPlaceholderUserEmail(current.email)
+ ) {
profileUpdate.email = input.email;
profileUpdate.emailVerified = input.emailVerified;
}
@@ -44,24 +50,39 @@ async function applySocialVerifiedEmailToUser(input: {
if (input.image && !current.image) {
profileUpdate.image = input.image;
}
-
- if (Object.keys(profileUpdate).length === 0) return;
-
- try {
- await authPrisma.user.update({
- where: { id: input.userId },
- data: profileUpdate,
- });
- } catch {
- // Unique email conflicts should not fail social login; VerifiedEmail still
- // holds the upstream mailbox for OAuth userinfo resolution.
+ if (Object.keys(profileUpdate).length > 0) {
+ try {
+ await authPrisma.user.update({
+ where: { id: input.userId },
+ data: profileUpdate,
+ });
+ } catch {
+ // Unique email conflicts should not fail social login; VerifiedEmail still
+ // holds the upstream mailbox for OAuth userinfo resolution.
+ }
+ }
+ if (input.image && !current.profilePictures.includes(input.image)) {
+ try {
+ await prisma.user.update({
+ where: { id: input.userId },
+ data: { profilePictures: { push: input.image } },
+ select: { id: true },
+ });
+ } catch (error) {
+ logAppEvent(
+ "warn",
+ "Failed to persist upstream avatar as a profile option",
+ { source: "auth" },
+ error,
+ );
+ }
}
}
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 +91,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/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts
index 3c17c15f0..c8d00ab28 100644
--- a/tests/e2e/src/app/_shared/page-inventory.ts
+++ b/tests/e2e/src/app/_shared/page-inventory.ts
@@ -233,12 +233,42 @@ 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",
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/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..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);
@@ -61,6 +65,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
@@ -68,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 {
@@ -99,6 +93,52 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => {
}
});
+test("/account/welcome 本地图片处理不可用时保留表单并显示错误", 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(/\/account\/welcome(?:\?.*)?$/);
+ await expect(
+ page.getByText(
+ /头像处理服务暂时不可用|Avatar processing is temporarily unavailable/i,
+ ),
+ ).toBeVisible();
+
+ const unchangedUser = await getUserProfileById(sessionUser.id);
+ expect(unchangedUser.image).toBe(originalUser.image);
+ } 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, "/");
@@ -158,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,
});
@@ -202,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,
});
@@ -220,46 +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();
+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/integration/rls-database-contract.test.ts b/tests/integration/rls-database-contract.test.ts
index b9eac9fcb..e3b793492 100644
--- a/tests/integration/rls-database-contract.test.ts
+++ b/tests/integration/rls-database-contract.test.ts
@@ -124,6 +124,35 @@ describe.skipIf(process.env.RLS_TEST_ENABLED !== "true")(
}
});
+ it("allows the app runtime to append trusted profile picture URLs", async () => {
+ const marker = `runtime-avatar-${crypto.randomUUID()}`;
+ const user = await adminPrisma.user.create({
+ data: {
+ email: `${marker}@example.test`,
+ name: marker,
+ },
+ select: { id: true },
+ });
+
+ try {
+ await expect(
+ prisma.user.update({
+ where: { id: user.id },
+ data: {
+ profilePictures: {
+ push: `https://example.test/${marker}.webp`,
+ },
+ },
+ select: { profilePictures: true },
+ }),
+ ).resolves.toEqual({
+ profilePictures: [`https://example.test/${marker}.webp`],
+ });
+ } finally {
+ await adminPrisma.user.delete({ where: { id: user.id } });
+ }
+ });
+
it("keeps exactly one runtime-applicable owner policy per table", async () => {
const policies = await prisma.$queryRaw<
{
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..4a0582d67
--- /dev/null
+++ b/tests/unit/profile-avatar-service.test.ts
@@ -0,0 +1,140 @@
+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,
+ PROFILE_AVATAR_MAX_BYTES,
+ processProfileAvatarUpload,
+} 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..38851da85 100644
--- a/tests/unit/profile-update-service.test.ts
+++ b/tests/unit/profile-update-service.test.ts
@@ -1,17 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-const { authApiMock, isPrismaUniqueConstraintErrorMock, prismaMock } =
- vi.hoisted(() => ({
- authApiMock: {
- updateUser: vi.fn(),
+const {
+ authApiMock,
+ isPrismaUniqueConstraintErrorMock,
+ logAppEventMock,
+ prismaMock,
+} = vi.hoisted(() => ({
+ authApiMock: {
+ updateUser: vi.fn(),
+ },
+ isPrismaUniqueConstraintErrorMock: vi.fn(),
+ logAppEventMock: vi.fn(),
+ prismaMock: {
+ user: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
},
- isPrismaUniqueConstraintErrorMock: vi.fn(),
- prismaMock: {
- user: {
- findUnique: vi.fn(),
- },
- },
- }));
+ },
+}));
vi.mock("@/lib/auth/core", () => ({
authApi: authApiMock,
@@ -25,6 +31,10 @@ vi.mock("@/lib/db/prisma", () => ({
prisma: prismaMock,
}));
+vi.mock("@/lib/log/app-logger", () => ({
+ logAppEvent: logAppEventMock,
+}));
+
const profileInput = {
headers: new Headers(),
image: null,
@@ -39,9 +49,151 @@ describe("updateOwnProfile", () => {
isPrismaUniqueConstraintErrorMock.mockReset();
isPrismaUniqueConstraintErrorMock.mockReturnValue(false);
prismaMock.user.findUnique.mockReset();
+ prismaMock.user.update.mockReset();
+ logAppEventMock.mockReset();
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,
+ });
+ expect(prismaMock.user.update).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: {
+ profilePictures: { push: image },
+ },
+ select: { id: true },
+ });
+ });
+
+ it("keeps profile completion successful if saving the reusable avatar option fails", async () => {
+ const headers = new Headers();
+ const storageError = new Error("profile picture list unavailable");
+ prismaMock.user.findUnique
+ .mockResolvedValueOnce({
+ id: "user-1",
+ image: null,
+ profilePictures: [],
+ })
+ .mockResolvedValueOnce(null);
+ prismaMock.user.update.mockRejectedValueOnce(storageError);
+ 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(logAppEventMock).toHaveBeenCalledWith(
+ "warn",
+ "Failed to persist processed avatar as a profile option",
+ { source: "profile" },
+ storageError,
+ );
+ });
+
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..b66888c19 100644
--- a/tests/unit/social-verified-email-sync.test.ts
+++ b/tests/unit/social-verified-email-sync.test.ts
@@ -7,6 +7,7 @@ import {
const verifiedEmailUpsertMock = vi.fn();
const userFindUniqueMock = vi.fn();
const userUpdateMock = vi.fn();
+const profilePictureUpdateMock = vi.fn();
vi.mock("@/lib/db/auth-prisma", () => ({
authPrisma: {
@@ -20,6 +21,14 @@ vi.mock("@/lib/db/auth-prisma", () => ({
},
}));
+vi.mock("@/lib/db/prisma", () => ({
+ prisma: {
+ user: {
+ update: (...args: unknown[]) => profilePictureUpdateMock(...args),
+ },
+ },
+}));
+
import { syncSocialVerifiedEmailFromAccountHook } from "@/lib/auth/social-verified-email-plugin";
describe("social verified email sync", () => {
@@ -28,6 +37,7 @@ describe("social verified email sync", () => {
verifiedEmailUpsertMock.mockReset();
userFindUniqueMock.mockReset();
userUpdateMock.mockReset();
+ profilePictureUpdateMock.mockReset();
});
it("persists GitHub email into VerifiedEmail and upgrades placeholder User.email", async () => {
@@ -44,6 +54,7 @@ describe("social verified email sync", () => {
email: "oidc-1@users.local",
name: "USTC User 1",
image: null,
+ profilePictures: [],
});
userUpdateMock.mockResolvedValue(undefined);
@@ -77,16 +88,55 @@ describe("social verified email sync", () => {
image: "https://example.com/octocat.png",
},
});
+ expect(profilePictureUpdateMock).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: {
+ profilePictures: { push: "https://example.com/octocat.png" },
+ },
+ select: { id: true },
+ });
});
- 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",
+ },
+ });
+ expect(profilePictureUpdateMock).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: {
+ profilePictures: { push: "https://example.com/ustc.png" },
+ },
+ select: { id: true },
+ });
+ });
+ 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..3e6ddb8ba
--- /dev/null
+++ b/tests/unit/welcome-complete-action.test.ts
@@ -0,0 +1,154 @@
+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:
+ "/account/welcome?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview",
+ 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-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-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);
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",
+ );
+ });
+});
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts
index 723d37186..4c12037cf 100644
--- a/worker-configuration.d.ts
+++ b/worker-configuration.d.ts
@@ -1,5 +1,5 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types --env-file=/dev/null --include-runtime=false` (hash: 3a7f42782d2c249ab96113b0f41e7b21)
+// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 2989d39f444b9fccae00dbb39e1d9800)
interface __BaseEnv_Env {
CALENDAR_EXPORTS: KVNamespace;
CATALOG_DETAIL_CORE: KVNamespace;
@@ -10,6 +10,7 @@ interface __BaseEnv_Env {
CALENDAR_EXPORT_REBUILD: Queue;
USER_WRITE_RATE_LIMITER: RateLimit;
USER_BATCH_WRITE_RATE_LIMITER: RateLimit;
+ IMAGES: ImagesBinding;
ASSETS: Fetcher;
NODE_ENV: "production";
APP_PUBLIC_ORIGIN: "https://life-ustc.tiankaima.dev";
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": {