feat: support guest session (#919)

This commit is contained in:
Jeremy 2025-04-25 23:40:15 -07:00 committed by GitHub
parent 24cb2ce19b
commit 9279135355
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 741 additions and 288 deletions

View file

@ -0,0 +1,21 @@
import { signIn } from '@/app/(auth)/auth';
import { isDevelopmentEnvironment } from '@/lib/constants';
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const redirectUrl = searchParams.get('redirectUrl') || '/';
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: !isDevelopmentEnvironment,
});
if (token) {
return NextResponse.redirect(new URL('/', request.url));
}
return signIn('guest', { redirect: true, redirectTo: redirectUrl });
}

View file

@ -9,31 +9,5 @@ export const authConfig = {
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js // added later in auth.ts since it requires bcrypt which is only compatible with Node.js
// while this file is also used in non-Node.js environments // while this file is also used in non-Node.js environments
], ],
callbacks: { callbacks: {},
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnChat = nextUrl.pathname.startsWith('/');
const isOnRegister = nextUrl.pathname.startsWith('/register');
const isOnLogin = nextUrl.pathname.startsWith('/login');
if (isLoggedIn && (isOnLogin || isOnRegister)) {
return Response.redirect(new URL('/', nextUrl as unknown as URL));
}
if (isOnRegister || isOnLogin) {
return true; // Always allow access to register and login pages
}
if (isOnChat) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
}
if (isLoggedIn) {
return Response.redirect(new URL('/', nextUrl as unknown as URL));
}
return true;
},
},
} satisfies NextAuthConfig; } satisfies NextAuthConfig;

View file

@ -1,14 +1,33 @@
import { compare } from 'bcrypt-ts'; import { compare } from 'bcrypt-ts';
import NextAuth, { type User, type Session } from 'next-auth'; import NextAuth, { type DefaultSession } from 'next-auth';
import Credentials from 'next-auth/providers/credentials'; import Credentials from 'next-auth/providers/credentials';
import { createGuestUser, getUser } from '@/lib/db/queries';
import { getUser } from '@/lib/db/queries';
import { authConfig } from './auth.config'; import { authConfig } from './auth.config';
import { DUMMY_PASSWORD } from '@/lib/constants'; import { DUMMY_PASSWORD } from '@/lib/constants';
import type { DefaultJWT } from 'next-auth/jwt';
interface ExtendedSession extends Session { export type UserType = 'guest' | 'regular';
user: User;
declare module 'next-auth' {
interface Session extends DefaultSession {
user: {
id: string;
type: UserType;
} & DefaultSession['user'];
}
interface User {
id?: string;
email?: string | null;
type: UserType;
}
}
declare module 'next-auth/jwt' {
interface JWT extends DefaultJWT {
id: string;
type: UserType;
}
} }
export const { export const {
@ -40,27 +59,31 @@ export const {
if (!passwordsMatch) return null; if (!passwordsMatch) return null;
return user as any; return { ...user, type: 'regular' };
},
}),
Credentials({
id: 'guest',
credentials: {},
async authorize() {
const [guestUser] = await createGuestUser();
return { ...guestUser, type: 'guest' };
}, },
}), }),
], ],
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
token.id = user.id; token.id = user.id as string;
token.type = user.type;
} }
return token; return token;
}, },
async session({ async session({ session, token }) {
session,
token,
}: {
session: ExtendedSession;
token: any;
}) {
if (session.user) { if (session.user) {
session.user.id = token.id as string; session.user.id = token.id;
session.user.type = token.type;
} }
return session; return session;

View file

@ -9,6 +9,7 @@ import { AuthForm } from '@/components/auth-form';
import { SubmitButton } from '@/components/submit-button'; import { SubmitButton } from '@/components/submit-button';
import { login, type LoginActionState } from '../actions'; import { login, type LoginActionState } from '../actions';
import { useSession } from 'next-auth/react';
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
@ -23,6 +24,8 @@ export default function Page() {
}, },
); );
const { update: updateSession } = useSession();
useEffect(() => { useEffect(() => {
if (state.status === 'failed') { if (state.status === 'failed') {
toast({ toast({
@ -36,6 +39,7 @@ export default function Page() {
}); });
} else if (state.status === 'success') { } else if (state.status === 'success') {
setIsSuccessful(true); setIsSuccessful(true);
updateSession();
router.refresh(); router.refresh();
} }
}, [state.status]); }, [state.status]);

View file

@ -9,6 +9,7 @@ import { SubmitButton } from '@/components/submit-button';
import { register, type RegisterActionState } from '../actions'; import { register, type RegisterActionState } from '../actions';
import { toast } from '@/components/toast'; import { toast } from '@/components/toast';
import { useSession } from 'next-auth/react';
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
@ -23,6 +24,8 @@ export default function Page() {
}, },
); );
const { update: updateSession } = useSession();
useEffect(() => { useEffect(() => {
if (state.status === 'user_exists') { if (state.status === 'user_exists') {
toast({ type: 'error', description: 'Account already exists!' }); toast({ type: 'error', description: 'Account already exists!' });
@ -37,6 +40,7 @@ export default function Page() {
toast({ type: 'success', description: 'Account created successfully!' }); toast({ type: 'success', description: 'Account created successfully!' });
setIsSuccessful(true); setIsSuccessful(true);
updateSession();
router.refresh(); router.refresh();
} }
}, [state]); }, [state]);

View file

@ -5,11 +5,12 @@ import {
smoothStream, smoothStream,
streamText, streamText,
} from 'ai'; } from 'ai';
import { auth } from '@/app/(auth)/auth'; import { auth, type UserType } from '@/app/(auth)/auth';
import { systemPrompt } from '@/lib/ai/prompts'; import { systemPrompt } from '@/lib/ai/prompts';
import { import {
deleteChatById, deleteChatById,
getChatById, getChatById,
getMessageCountByUserId,
saveChat, saveChat,
saveMessages, saveMessages,
} from '@/lib/db/queries'; } from '@/lib/db/queries';
@ -25,6 +26,7 @@ import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
import { getWeather } from '@/lib/ai/tools/get-weather'; import { getWeather } from '@/lib/ai/tools/get-weather';
import { isProductionEnvironment } from '@/lib/constants'; import { isProductionEnvironment } from '@/lib/constants';
import { myProvider } from '@/lib/ai/providers'; import { myProvider } from '@/lib/ai/providers';
import { entitlementsByUserType } from '@/lib/ai/entitlements';
export const maxDuration = 60; export const maxDuration = 60;
@ -46,6 +48,22 @@ export async function POST(request: Request) {
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
const userType: UserType = session.user.type;
const messageCount = await getMessageCountByUserId({
id: session.user.id,
differenceInHours: 24,
});
if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) {
return new Response(
'You have exceeded your maximum number of messages for the day! Please try again later.',
{
status: 429,
},
);
}
const userMessage = getMostRecentUserMessage(messages); const userMessage = getMostRecentUserMessage(messages);
if (!userMessage) { if (!userMessage) {

View file

@ -1,13 +1,13 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { notFound } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { auth } from '@/app/(auth)/auth'; import { auth } from '@/app/(auth)/auth';
import { Chat } from '@/components/chat'; import { Chat } from '@/components/chat';
import { getChatById, getMessagesByChatId } from '@/lib/db/queries'; import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
import { DataStreamHandler } from '@/components/data-stream-handler'; import { DataStreamHandler } from '@/components/data-stream-handler';
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
import { DBMessage } from '@/lib/db/schema'; import type { DBMessage } from '@/lib/db/schema';
import { Attachment, UIMessage } from 'ai'; import type { Attachment, UIMessage } from 'ai';
export default async function Page(props: { params: Promise<{ id: string }> }) { export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params; const params = await props.params;
@ -20,8 +20,12 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) {
redirect('/api/auth/guest');
}
if (chat.visibility === 'private') { if (chat.visibility === 'private') {
if (!session || !session.user) { if (!session.user) {
return notFound(); return notFound();
} }
@ -59,6 +63,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
selectedChatModel={DEFAULT_CHAT_MODEL} selectedChatModel={DEFAULT_CHAT_MODEL}
selectedVisibilityType={chat.visibility} selectedVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId} isReadonly={session?.user?.id !== chat.userId}
session={session}
/> />
<DataStreamHandler id={id} /> <DataStreamHandler id={id} />
</> </>
@ -73,6 +78,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
selectedChatModel={chatModelFromCookie.value} selectedChatModel={chatModelFromCookie.value}
selectedVisibilityType={chat.visibility} selectedVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId} isReadonly={session?.user?.id !== chat.userId}
session={session}
/> />
<DataStreamHandler id={id} /> <DataStreamHandler id={id} />
</> </>

View file

@ -4,8 +4,16 @@ import { Chat } from '@/components/chat';
import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models'; import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
import { generateUUID } from '@/lib/utils'; import { generateUUID } from '@/lib/utils';
import { DataStreamHandler } from '@/components/data-stream-handler'; import { DataStreamHandler } from '@/components/data-stream-handler';
import { auth } from '../(auth)/auth';
import { redirect } from 'next/navigation';
export default async function Page() { export default async function Page() {
const session = await auth();
if (!session) {
redirect('/api/auth/guest');
}
const id = generateUUID(); const id = generateUUID();
const cookieStore = await cookies(); const cookieStore = await cookies();
@ -21,6 +29,7 @@ export default async function Page() {
selectedChatModel={DEFAULT_CHAT_MODEL} selectedChatModel={DEFAULT_CHAT_MODEL}
selectedVisibilityType="private" selectedVisibilityType="private"
isReadonly={false} isReadonly={false}
session={session}
/> />
<DataStreamHandler id={id} /> <DataStreamHandler id={id} />
</> </>
@ -36,6 +45,7 @@ export default async function Page() {
selectedChatModel={modelIdFromCookie.value} selectedChatModel={modelIdFromCookie.value}
selectedVisibilityType="private" selectedVisibilityType="private"
isReadonly={false} isReadonly={false}
session={session}
/> />
<DataStreamHandler id={id} /> <DataStreamHandler id={id} />
</> </>

View file

@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google';
import { ThemeProvider } from '@/components/theme-provider'; import { ThemeProvider } from '@/components/theme-provider';
import './globals.css'; import './globals.css';
import { SessionProvider } from 'next-auth/react';
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL('https://chat.vercel.ai'), metadataBase: new URL('https://chat.vercel.ai'),
@ -77,7 +78,7 @@ export default async function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<Toaster position="top-center" /> <Toaster position="top-center" />
{children} <SessionProvider>{children}</SessionProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View file

@ -12,17 +12,20 @@ import { useSidebar } from './ui/sidebar';
import { memo } from 'react'; import { memo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { type VisibilityType, VisibilitySelector } from './visibility-selector'; import { type VisibilityType, VisibilitySelector } from './visibility-selector';
import type { Session } from 'next-auth';
function PureChatHeader({ function PureChatHeader({
chatId, chatId,
selectedModelId, selectedModelId,
selectedVisibilityType, selectedVisibilityType,
isReadonly, isReadonly,
session,
}: { }: {
chatId: string; chatId: string;
selectedModelId: string; selectedModelId: string;
selectedVisibilityType: VisibilityType; selectedVisibilityType: VisibilityType;
isReadonly: boolean; isReadonly: boolean;
session: Session;
}) { }) {
const router = useRouter(); const router = useRouter();
const { open } = useSidebar(); const { open } = useSidebar();
@ -54,6 +57,7 @@ function PureChatHeader({
{!isReadonly && ( {!isReadonly && (
<ModelSelector <ModelSelector
session={session}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
className="order-1 md:order-2" className="order-1 md:order-2"
/> />

View file

@ -12,9 +12,10 @@ import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages'; import { Messages } from './messages';
import type { VisibilityType } from './visibility-selector'; import type { VisibilityType } from './visibility-selector';
import { useArtifactSelector } from '@/hooks/use-artifact'; import { useArtifactSelector } from '@/hooks/use-artifact';
import { toast } from 'sonner';
import { unstable_serialize } from 'swr/infinite'; import { unstable_serialize } from 'swr/infinite';
import { getChatHistoryPaginationKey } from './sidebar-history'; import { getChatHistoryPaginationKey } from './sidebar-history';
import { toast } from './toast';
import type { Session } from 'next-auth';
export function Chat({ export function Chat({
id, id,
@ -22,12 +23,14 @@ export function Chat({
selectedChatModel, selectedChatModel,
selectedVisibilityType, selectedVisibilityType,
isReadonly, isReadonly,
session,
}: { }: {
id: string; id: string;
initialMessages: Array<UIMessage>; initialMessages: Array<UIMessage>;
selectedChatModel: string; selectedChatModel: string;
selectedVisibilityType: VisibilityType; selectedVisibilityType: VisibilityType;
isReadonly: boolean; isReadonly: boolean;
session: Session;
}) { }) {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
@ -51,8 +54,11 @@ export function Chat({
onFinish: () => { onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey)); mutate(unstable_serialize(getChatHistoryPaginationKey));
}, },
onError: () => { onError: (error) => {
toast.error('An error occurred, please try again!'); toast({
type: 'error',
description: error.message,
});
}, },
}); });
@ -72,6 +78,7 @@ export function Chat({
selectedModelId={selectedChatModel} selectedModelId={selectedChatModel}
selectedVisibilityType={selectedVisibilityType} selectedVisibilityType={selectedVisibilityType}
isReadonly={isReadonly} isReadonly={isReadonly}
session={session}
/> />
<Messages <Messages

View file

@ -14,20 +14,34 @@ import { chatModels } from '@/lib/ai/models';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CheckCircleFillIcon, ChevronDownIcon } from './icons'; import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
import { entitlementsByUserType } from '@/lib/ai/entitlements';
import type { Session } from 'next-auth';
export function ModelSelector({ export function ModelSelector({
session,
selectedModelId, selectedModelId,
className, className,
}: { }: {
session: Session;
selectedModelId: string; selectedModelId: string;
} & React.ComponentProps<typeof Button>) { } & React.ComponentProps<typeof Button>) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [optimisticModelId, setOptimisticModelId] = const [optimisticModelId, setOptimisticModelId] =
useOptimistic(selectedModelId); useOptimistic(selectedModelId);
const userType = session.user.type;
const { availableChatModelIds } = entitlementsByUserType[userType];
const availableChatModels = chatModels.filter((chatModel) =>
availableChatModelIds.includes(chatModel.id),
);
const selectedChatModel = useMemo( const selectedChatModel = useMemo(
() => chatModels.find((chatModel) => chatModel.id === optimisticModelId), () =>
[optimisticModelId], availableChatModels.find(
(chatModel) => chatModel.id === optimisticModelId,
),
[optimisticModelId, availableChatModels],
); );
return ( return (
@ -49,7 +63,7 @@ export function ModelSelector({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]"> <DropdownMenuContent align="start" className="min-w-[300px]">
{chatModels.map((chatModel) => { {availableChatModels.map((chatModel) => {
const { id } = chatModel; const { id } = chatModel;
return ( return (

View file

@ -19,6 +19,7 @@ export function SidebarToggle({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
data-testid="sidebar-toggle-button"
onClick={toggleSidebar} onClick={toggleSidebar}
variant="outline" variant="outline"
className="md:px-2 md:h-fit" className="md:px-2 md:h-fit"

View file

@ -1,8 +1,9 @@
'use client'; 'use client';
import { ChevronUp } from 'lucide-react'; import { ChevronUp } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import type { User } from 'next-auth'; import type { User } from 'next-auth';
import { signOut } from 'next-auth/react'; import { signOut, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { import {
@ -17,49 +18,92 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useRouter } from 'next/navigation';
import { toast } from './toast';
import { LoaderIcon } from './icons';
import { guestRegex } from '@/lib/constants';
export function SidebarUserNav({ user }: { user: User }) { export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, status } = useSession();
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const isGuest = guestRegex.test(data?.user?.email ?? '');
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10"> {status === 'loading' ? (
<Image <SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10 justify-between">
src={`https://avatar.vercel.sh/${user.email}`} <div className="flex flex-row gap-2">
alt={user.email ?? 'User Avatar'} <div className="size-6 bg-zinc-500/30 rounded-full animate-pulse" />
width={24} <span className="bg-zinc-500/30 text-transparent rounded-md animate-pulse">
height={24} Loading auth status
className="rounded-full" </span>
/> </div>
<span className="truncate">{user?.email}</span> <div className="animate-spin text-zinc-500">
<ChevronUp className="ml-auto" /> <LoaderIcon />
</SidebarMenuButton> </div>
</SidebarMenuButton>
) : (
<SidebarMenuButton
data-testid="user-nav-button"
className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10"
>
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span data-testid="user-email" className="truncate">
{isGuest ? 'Guest' : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
data-testid="user-nav-menu"
side="top" side="top"
className="w-[--radix-popper-anchor-width]" className="w-[--radix-popper-anchor-width]"
> >
<DropdownMenuItem <DropdownMenuItem
data-testid="user-nav-item-theme"
className="cursor-pointer" className="cursor-pointer"
onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
> >
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`} {`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button <button
type="button" type="button"
className="w-full cursor-pointer" className="w-full cursor-pointer"
onClick={() => { onClick={() => {
signOut({ if (status === 'loading') {
redirectTo: '/', toast({
}); type: 'error',
description:
'Checking authentication status, please try again!',
});
return;
}
if (isGuest) {
router.push('/login');
} else {
signOut({
redirectTo: '/',
});
}
}} }}
> >
Sign out {isGuest ? 'Login to your account' : 'Sign out'}
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View file

@ -1,8 +1,9 @@
'use client'; 'use client';
import React, { ReactNode } from 'react'; import React, { useEffect, useRef, useState, type ReactNode } from 'react';
import { toast as sonnerToast } from 'sonner'; import { toast as sonnerToast } from 'sonner';
import { CheckCircleFillIcon, WarningIcon } from './icons'; import { CheckCircleFillIcon, WarningIcon } from './icons';
import { cn } from '@/lib/utils';
const iconsByType: Record<'success' | 'error', ReactNode> = { const iconsByType: Record<'success' | 'error', ReactNode> = {
success: <CheckCircleFillIcon />, success: <CheckCircleFillIcon />,
@ -18,20 +19,48 @@ export function toast(props: Omit<ToastProps, 'id'>) {
function Toast(props: ToastProps) { function Toast(props: ToastProps) {
const { id, type, description } = props; const { id, type, description } = props;
const descriptionRef = useRef<HTMLDivElement>(null);
const [multiLine, setMultiLine] = useState(false);
useEffect(() => {
const el = descriptionRef.current;
if (!el) return;
const update = () => {
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
const lines = Math.round(el.scrollHeight / lineHeight);
setMultiLine(lines > 1);
};
update(); // initial check
const ro = new ResizeObserver(update); // re-check on width changes
ro.observe(el);
return () => ro.disconnect();
}, [description]);
return ( return (
<div className="flex w-full toast-mobile:w-[356px] justify-center"> <div className="flex w-full toast-mobile:w-[356px] justify-center">
<div <div
data-testid="toast" data-testid="toast"
key={id} key={id}
className="bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-2 items-center" className={cn(
'bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-3',
multiLine ? 'items-start' : 'items-center',
)}
> >
<div <div
data-type={type} data-type={type}
className="data-[type=error]:text-red-600 data-[type=success]:text-green-600" className={cn(
'data-[type=error]:text-red-600 data-[type=success]:text-green-600',
{ 'pt-1': multiLine },
)}
> >
{iconsByType[type]} {iconsByType[type]}
</div> </div>
<div className="text-zinc-950 text-sm">{description}</div> <div ref={descriptionRef} className="text-zinc-950 text-sm">
{description}
</div>
</div> </div>
</div> </div>
); );

29
lib/ai/entitlements.ts Normal file
View file

@ -0,0 +1,29 @@
import type { UserType } from '@/app/(auth)/auth';
import type { ChatModel } from './models';
interface Entitlements {
maxMessagesPerDay: number;
availableChatModelIds: Array<ChatModel['id']>;
}
export const entitlementsByUserType: Record<UserType, Entitlements> = {
/*
* For users without an account
*/
guest: {
maxMessagesPerDay: 20,
availableChatModelIds: ['chat-model', 'chat-model-reasoning'],
},
/*
* For users with an account
*/
regular: {
maxMessagesPerDay: 100,
availableChatModelIds: ['chat-model', 'chat-model-reasoning'],
},
/*
* TODO: For users with an account and a paid membership
*/
};

View file

@ -1,6 +1,6 @@
export const DEFAULT_CHAT_MODEL: string = 'chat-model'; export const DEFAULT_CHAT_MODEL: string = 'chat-model';
interface ChatModel { export interface ChatModel {
id: string; id: string;
name: string; name: string;
description: string; description: string;

View file

@ -1,11 +1,13 @@
import { generateDummyPassword } from './db/utils'; import { generateDummyPassword } from './db/utils';
export const isProductionEnvironment = process.env.NODE_ENV === 'production'; export const isProductionEnvironment = process.env.NODE_ENV === 'production';
export const isDevelopmentEnvironment = process.env.NODE_ENV === 'development';
export const isTestEnvironment = Boolean( export const isTestEnvironment = Boolean(
process.env.PLAYWRIGHT_TEST_BASE_URL || process.env.PLAYWRIGHT_TEST_BASE_URL ||
process.env.PLAYWRIGHT || process.env.PLAYWRIGHT ||
process.env.CI_PLAYWRIGHT, process.env.CI_PLAYWRIGHT,
); );
export const guestRegex = /^guest-\d+$/;
export const DUMMY_PASSWORD = generateDummyPassword(); export const DUMMY_PASSWORD = generateDummyPassword();

View file

@ -3,6 +3,7 @@ import 'server-only';
import { import {
and, and,
asc, asc,
count,
desc, desc,
eq, eq,
gt, gt,
@ -27,6 +28,7 @@ import {
type Chat, type Chat,
} from './schema'; } from './schema';
import type { ArtifactKind } from '@/components/artifact'; import type { ArtifactKind } from '@/components/artifact';
import { generateUUID } from '../utils';
import { generateHashedPassword } from './utils'; import { generateHashedPassword } from './utils';
// Optionally, if not using email/pass login, you can // Optionally, if not using email/pass login, you can
@ -57,6 +59,21 @@ export async function createUser(email: string, password: string) {
} }
} }
export async function createGuestUser() {
const email = `guest-${Date.now()}`;
const password = generateHashedPassword(generateUUID());
try {
return await db.insert(user).values({ email, password }).returning({
id: user.id,
email: user.email,
});
} catch (error) {
console.error('Failed to create guest user in database');
throw error;
}
}
export async function saveChat({ export async function saveChat({
id, id,
userId, userId,
@ -422,3 +439,34 @@ export async function updateChatVisiblityById({
throw error; throw error;
} }
} }
export async function getMessageCountByUserId({
id,
differenceInHours,
}: { id: string; differenceInHours: number }) {
try {
const twentyFourHoursAgo = new Date(
Date.now() - differenceInHours * 60 * 60 * 1000,
);
const [stats] = await db
.select({ count: count(message.id) })
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.where(
and(
eq(chat.userId, id),
gte(message.createdAt, twentyFourHoursAgo),
eq(message.role, 'user'),
),
)
.execute();
return stats?.count ?? 0;
} catch (error) {
console.error(
'Failed to get message count by user id for the last 24 hours from database',
);
throw error;
}
}

View file

@ -1,9 +1,59 @@
import NextAuth from 'next-auth'; import { NextResponse, type NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { guestRegex, isDevelopmentEnvironment } from './lib/constants';
import { authConfig } from '@/app/(auth)/auth.config'; export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
export default NextAuth(authConfig).auth; /*
* Playwright starts the dev server and requires a 200 status to
* begin the tests, so this ensures that the tests can start
*/
if (pathname.startsWith('/ping')) {
return new Response('pong', { status: 200 });
}
if (pathname.startsWith('/api/auth')) {
return NextResponse.next();
}
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: !isDevelopmentEnvironment,
});
if (!token) {
const redirectUrl = encodeURIComponent(request.url);
return NextResponse.redirect(
new URL(`/api/auth/guest?redirectUrl=${redirectUrl}`, request.url),
);
}
const isGuest = guestRegex.test(token?.email ?? '');
if (token && !isGuest && ['/login', '/register'].includes(pathname)) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = { export const config = {
matcher: ['/', '/:id', '/api/:path*', '/login', '/register'], matcher: [
'/',
'/chat/:id',
'/api/:path*',
'/login',
'/register',
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
}; };

View file

@ -1,6 +1,6 @@
{ {
"name": "ai-chatbot", "name": "ai-chatbot",
"version": "3.0.6", "version": "3.0.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev --turbo",

View file

@ -29,9 +29,9 @@ export default defineConfig({
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 1, retries: 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: 1, workers: process.env.CI ? 2 : 8,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
@ -40,61 +40,27 @@ export default defineConfig({
baseURL, baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'retain-on-failure',
}, },
/* Configure global timeout for each test */ /* Configure global timeout for each test */
timeout: 60 * 1000, // 30 seconds timeout: 120 * 1000, // 120 seconds
expect: { expect: {
timeout: 60 * 1000, timeout: 120 * 1000,
}, },
/* Configure projects */ /* Configure projects */
projects: [ projects: [
{ {
name: 'setup:auth', name: 'e2e',
testMatch: /e2e\/auth.setup.ts/, testMatch: /e2e\/.*.test.ts/,
},
{
name: 'setup:reasoning',
testMatch: /e2e\/reasoning.setup.ts/,
dependencies: ['setup:auth'],
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
storageState: 'playwright/.auth/session.json',
},
},
{
name: 'chat',
testMatch: /e2e\/chat.test.ts/,
dependencies: ['setup:auth'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/session.json',
},
},
{
name: 'reasoning',
testMatch: /e2e\/reasoning.test.ts/,
dependencies: ['setup:reasoning'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.reasoning/session.json',
},
},
{
name: 'artifacts',
testMatch: /e2e\/artifacts.test.ts/,
dependencies: ['setup:auth'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/session.json',
}, },
}, },
{ {
name: 'routes', name: 'routes',
testMatch: /routes\/.*.test.ts/, testMatch: /routes\/.*.test.ts/,
dependencies: [],
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
}, },
@ -134,7 +100,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'pnpm dev', command: 'pnpm dev',
url: baseURL, url: `${baseURL}/ping`,
timeout: 120 * 1000, timeout: 120 * 1000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '../fixtures';
import { ChatPage } from '../pages/chat'; import { ChatPage } from '../pages/chat';
import { ArtifactPage } from '../pages/artifact'; import { ArtifactPage } from '../pages/artifact';
test.describe('artifacts activity', () => { test.describe('Artifacts activity', () => {
let chatPage: ChatPage; let chatPage: ChatPage;
let artifactPage: ArtifactPage; let artifactPage: ArtifactPage;
@ -13,7 +13,7 @@ test.describe('artifacts activity', () => {
await chatPage.createNewChat(); await chatPage.createNewChat();
}); });
test('create a text artifact', async () => { test('Create a text artifact', async () => {
await chatPage.createNewChat(); await chatPage.createNewChat();
await chatPage.sendUserMessage( await chatPage.sendUserMessage(
@ -31,7 +31,7 @@ test.describe('artifacts activity', () => {
await chatPage.hasChatIdInUrl(); await chatPage.hasChatIdInUrl();
}); });
test('toggle artifact visibility', async () => { test('Toggle artifact visibility', async () => {
await chatPage.createNewChat(); await chatPage.createNewChat();
await chatPage.sendUserMessage( await chatPage.sendUserMessage(
@ -50,7 +50,7 @@ test.describe('artifacts activity', () => {
await chatPage.isElementNotVisible('artifact'); await chatPage.isElementNotVisible('artifact');
}); });
test('send follow up message after generation', async () => { test('Send follow up message after generation', async () => {
await chatPage.createNewChat(); await chatPage.createNewChat();
await chatPage.sendUserMessage( await chatPage.sendUserMessage(

View file

@ -1,24 +0,0 @@
import path from 'node:path';
import { generateId } from 'ai';
import { getUnixTime } from 'date-fns';
import { expect, test as setup } from '@playwright/test';
const authFile = path.join(__dirname, '../../playwright/.auth/session.json');
setup('authenticate', async ({ page }) => {
const testEmail = `test-${getUnixTime(new Date())}@playwright.com`;
const testPassword = generateId(16);
await page.goto('http://localhost:3000/register');
await page.getByPlaceholder('user@acme.com').click();
await page.getByPlaceholder('user@acme.com').fill(testEmail);
await page.getByLabel('Password').click();
await page.getByLabel('Password').fill(testPassword);
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByTestId('toast')).toContainText(
'Account created successfully!',
);
await page.context().storageState({ path: authFile });
});

View file

@ -1,77 +0,0 @@
import { generateId } from 'ai';
import { getUnixTime } from 'date-fns';
import { test, expect, type Page } from '@playwright/test';
test.use({ storageState: { cookies: [], origins: [] } });
const testEmail = `test-${getUnixTime(new Date())}@playwright.com`;
const testPassword = generateId(16);
class AuthPage {
constructor(private page: Page) {}
async gotoLogin() {
await this.page.goto('/login');
await expect(this.page.getByRole('heading')).toContainText('Sign In');
}
async gotoRegister() {
await this.page.goto('/register');
await expect(this.page.getByRole('heading')).toContainText('Sign Up');
}
async register(email: string, password: string) {
await this.gotoRegister();
await this.page.getByPlaceholder('user@acme.com').click();
await this.page.getByPlaceholder('user@acme.com').fill(email);
await this.page.getByLabel('Password').click();
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign Up' }).click();
}
async login(email: string, password: string) {
await this.gotoLogin();
await this.page.getByPlaceholder('user@acme.com').click();
await this.page.getByPlaceholder('user@acme.com').fill(email);
await this.page.getByLabel('Password').click();
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign In' }).click();
}
async expectToastToContain(text: string) {
await expect(this.page.getByTestId('toast')).toContainText(text);
}
}
test.describe
.serial('authentication', () => {
let authPage: AuthPage;
test.beforeEach(async ({ page }) => {
authPage = new AuthPage(page);
});
test('redirect to login page when unauthenticated', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading')).toContainText('Sign In');
});
test('register a test account', async ({ page }) => {
await authPage.register(testEmail, testPassword);
await expect(page).toHaveURL('/');
await authPage.expectToastToContain('Account created successfully!');
});
test('register test account with existing email', async () => {
await authPage.register(testEmail, testPassword);
await authPage.expectToastToContain('Account already exists!');
});
test('log into account', async ({ page }) => {
await authPage.login(testEmail, testPassword);
await page.waitForURL('/');
await expect(page).toHaveURL('/');
await expect(page.getByPlaceholder('Send a message...')).toBeVisible();
});
});

View file

@ -1,7 +1,7 @@
import { ChatPage } from '../pages/chat'; import { ChatPage } from '../pages/chat';
import { test, expect } from '@playwright/test'; import { test, expect } from '../fixtures';
test.describe('chat activity', () => { test.describe('Chat activity', () => {
let chatPage: ChatPage; let chatPage: ChatPage;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -9,7 +9,7 @@ test.describe('chat activity', () => {
await chatPage.createNewChat(); await chatPage.createNewChat();
}); });
test('send a user message and receive response', async () => { test('Send a user message and receive response', async () => {
await chatPage.sendUserMessage('Why is grass green?'); await chatPage.sendUserMessage('Why is grass green?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -17,7 +17,7 @@ test.describe('chat activity', () => {
expect(assistantMessage.content).toContain("It's just green duh!"); expect(assistantMessage.content).toContain("It's just green duh!");
}); });
test('redirect to /chat/:id after submitting message', async () => { test('Redirect to /chat/:id after submitting message', async () => {
await chatPage.sendUserMessage('Why is grass green?'); await chatPage.sendUserMessage('Why is grass green?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -26,7 +26,7 @@ test.describe('chat activity', () => {
await chatPage.hasChatIdInUrl(); await chatPage.hasChatIdInUrl();
}); });
test('send a user message from suggestion', async () => { test('Send a user message from suggestion', async () => {
await chatPage.sendUserMessageFromSuggestion(); await chatPage.sendUserMessageFromSuggestion();
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -36,7 +36,7 @@ test.describe('chat activity', () => {
); );
}); });
test('toggle between send/stop button based on activity', async () => { test('Toggle between send/stop button based on activity', async () => {
await expect(chatPage.sendButton).toBeVisible(); await expect(chatPage.sendButton).toBeVisible();
await expect(chatPage.sendButton).toBeDisabled(); await expect(chatPage.sendButton).toBeDisabled();
@ -51,14 +51,14 @@ test.describe('chat activity', () => {
await expect(chatPage.sendButton).toBeVisible(); await expect(chatPage.sendButton).toBeVisible();
}); });
test('stop generation during submission', async () => { test('Stop generation during submission', async () => {
await chatPage.sendUserMessage('Why is grass green?'); await chatPage.sendUserMessage('Why is grass green?');
await expect(chatPage.stopButton).toBeVisible(); await expect(chatPage.stopButton).toBeVisible();
await chatPage.stopButton.click(); await chatPage.stopButton.click();
await expect(chatPage.sendButton).toBeVisible(); await expect(chatPage.sendButton).toBeVisible();
}); });
test('edit user message and resubmit', async () => { test('Edit user message and resubmit', async () => {
await chatPage.sendUserMessage('Why is grass green?'); await chatPage.sendUserMessage('Why is grass green?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -74,13 +74,13 @@ test.describe('chat activity', () => {
expect(updatedAssistantMessage.content).toContain("It's just blue duh!"); expect(updatedAssistantMessage.content).toContain("It's just blue duh!");
}); });
test('hide suggested actions after sending message', async () => { test('Hide suggested actions after sending message', async () => {
await chatPage.isElementVisible('suggested-actions'); await chatPage.isElementVisible('suggested-actions');
await chatPage.sendUserMessageFromSuggestion(); await chatPage.sendUserMessageFromSuggestion();
await chatPage.isElementNotVisible('suggested-actions'); await chatPage.isElementNotVisible('suggested-actions');
}); });
test('upload file and send image attachment with message', async () => { test('Upload file and send image attachment with message', async () => {
await chatPage.addImageAttachment(); await chatPage.addImageAttachment();
await chatPage.isElementVisible('attachments-preview'); await chatPage.isElementVisible('attachments-preview');
@ -98,7 +98,7 @@ test.describe('chat activity', () => {
expect(assistantMessage.content).toBe('This painting is by Monet!'); expect(assistantMessage.content).toBe('This painting is by Monet!');
}); });
test('call weather tool', async () => { test('Call weather tool', async () => {
await chatPage.sendUserMessage("What's the weather in sf?"); await chatPage.sendUserMessage("What's the weather in sf?");
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -109,7 +109,7 @@ test.describe('chat activity', () => {
); );
}); });
test('upvote message', async () => { test('Upvote message', async () => {
await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -118,7 +118,7 @@ test.describe('chat activity', () => {
await chatPage.isVoteComplete(); await chatPage.isVoteComplete();
}); });
test('downvote message', async () => { test('Downvote message', async () => {
await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -127,7 +127,7 @@ test.describe('chat activity', () => {
await chatPage.isVoteComplete(); await chatPage.isVoteComplete();
}); });
test('update vote', async () => { test('Update vote', async () => {
await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();

View file

@ -1,20 +0,0 @@
import path from 'node:path';
import { expect, test as setup } from '@playwright/test';
import { ChatPage } from '../pages/chat';
const reasoningFile = path.join(
__dirname,
'../../playwright/.reasoning/session.json',
);
setup('switch to reasoning model', async ({ page }) => {
const chatPage = new ChatPage(page);
await chatPage.createNewChat();
await chatPage.chooseModelFromSelector('chat-model-reasoning');
await expect(chatPage.getSelectedModel()).resolves.toEqual('Reasoning model');
await page.waitForTimeout(1000);
await page.context().storageState({ path: reasoningFile });
});

View file

@ -1,15 +1,15 @@
import { ChatPage } from '../pages/chat'; import { ChatPage } from '../pages/chat';
import { test, expect } from '@playwright/test'; import { test, expect } from '../fixtures';
test.describe('chat activity with reasoning', () => { test.describe('chat activity with reasoning', () => {
let chatPage: ChatPage; let chatPage: ChatPage;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ curieContext }) => {
chatPage = new ChatPage(page); chatPage = new ChatPage(curieContext.page);
await chatPage.createNewChat(); await chatPage.createNewChat();
}); });
test('send user message and generate response with reasoning', async () => { test('Curie can send message and generate response with reasoning', async () => {
await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -21,7 +21,7 @@ test.describe('chat activity with reasoning', () => {
); );
}); });
test('toggle reasoning visibility', async () => { test('Curie can toggle reasoning visibility', async () => {
await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();
@ -37,7 +37,7 @@ test.describe('chat activity with reasoning', () => {
await expect(reasoningElement).toBeVisible(); await expect(reasoningElement).toBeVisible();
}); });
test('edit message and resubmit', async () => { test('Curie can edit message and resubmit', async () => {
await chatPage.sendUserMessage('Why is the sky blue?'); await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete(); await chatPage.isGenerationComplete();

207
tests/e2e/session.test.ts Normal file
View file

@ -0,0 +1,207 @@
import { expect, test } from '../fixtures';
import { AuthPage } from '../pages/auth';
import { generateRandomTestUser } from '../helpers';
import { ChatPage } from '../pages/chat';
test.describe
.serial('Guest Session', () => {
test('Authenticate as guest user when a new session is loaded', async ({
page,
}) => {
const response = await page.goto('/');
if (!response) {
throw new Error('Failed to load page');
}
let request = response.request();
const chain = [];
while (request) {
chain.unshift(request.url());
request = request.redirectedFrom();
}
expect(chain).toEqual([
'http://localhost:3000/',
'http://localhost:3000/api/auth/guest?redirectUrl=http%3A%2F%2Flocalhost%3A3000%2F',
'http://localhost:3000/',
]);
});
test('Log out is not available for guest users', async ({ page }) => {
await page.goto('/');
const sidebarToggleButton = page.getByTestId('sidebar-toggle-button');
await sidebarToggleButton.click();
const userNavButton = page.getByTestId('user-nav-button');
await expect(userNavButton).toBeVisible();
await userNavButton.click();
const userNavMenu = page.getByTestId('user-nav-menu');
await expect(userNavMenu).toBeVisible();
const authMenuItem = page.getByTestId('user-nav-item-auth');
await expect(authMenuItem).toContainText('Login to your account');
});
test('Do not authenticate as guest user when an existing non-guest session is active', async ({
adaContext,
}) => {
const response = await adaContext.page.goto('/');
if (!response) {
throw new Error('Failed to load page');
}
let request = response.request();
const chain = [];
while (request) {
chain.unshift(request.url());
request = request.redirectedFrom();
}
expect(chain).toEqual(['http://localhost:3000/']);
});
test('Allow navigating to /login as guest user', async ({ page }) => {
await page.goto('/login');
await page.waitForURL('/login');
await expect(page).toHaveURL('/login');
});
test('Allow navigating to /register as guest user', async ({ page }) => {
await page.goto('/register');
await page.waitForURL('/register');
await expect(page).toHaveURL('/register');
});
test('Do not show email in user menu for guest user', async ({ page }) => {
await page.goto('/');
const sidebarToggleButton = page.getByTestId('sidebar-toggle-button');
await sidebarToggleButton.click();
const userEmail = page.getByTestId('user-email');
await expect(userEmail).toContainText('Guest');
});
});
test.describe
.serial('Login and Registration', () => {
let authPage: AuthPage;
const testUser = generateRandomTestUser();
test.beforeEach(async ({ page }) => {
authPage = new AuthPage(page);
});
test('Register new account', async () => {
await authPage.register(testUser.email, testUser.password);
await authPage.expectToastToContain('Account created successfully!');
});
test('Register new account with existing email', async () => {
await authPage.register(testUser.email, testUser.password);
await authPage.expectToastToContain('Account already exists!');
});
test('Log into account that exists', async ({ page }) => {
await authPage.login(testUser.email, testUser.password);
await page.waitForURL('/');
await expect(page.getByPlaceholder('Send a message...')).toBeVisible();
});
test('Display user email in user menu', async ({ page }) => {
await authPage.login(testUser.email, testUser.password);
await page.waitForURL('/');
await expect(page.getByPlaceholder('Send a message...')).toBeVisible();
const userEmail = await page.getByTestId('user-email');
await expect(userEmail).toHaveText(testUser.email);
});
test('Log out as non-guest user', async () => {
await authPage.logout(testUser.email, testUser.password);
});
test('Do not force create a guest session if non-guest session already exists', async ({
page,
}) => {
await authPage.login(testUser.email, testUser.password);
await page.waitForURL('/');
const userEmail = await page.getByTestId('user-email');
await expect(userEmail).toHaveText(testUser.email);
await page.goto('/api/auth/guest');
await page.waitForURL('/');
const updatedUserEmail = await page.getByTestId('user-email');
await expect(updatedUserEmail).toHaveText(testUser.email);
});
test('Log out is available for non-guest users', async ({ page }) => {
await authPage.login(testUser.email, testUser.password);
await page.waitForURL('/');
authPage.openSidebar();
const userNavButton = page.getByTestId('user-nav-button');
await expect(userNavButton).toBeVisible();
await userNavButton.click();
const userNavMenu = page.getByTestId('user-nav-menu');
await expect(userNavMenu).toBeVisible();
const authMenuItem = page.getByTestId('user-nav-item-auth');
await expect(authMenuItem).toContainText('Sign out');
});
test('Do not navigate to /register for non-guest users', async ({
page,
}) => {
await authPage.login(testUser.email, testUser.password);
await page.waitForURL('/');
await page.goto('/register');
await expect(page).toHaveURL('/');
});
test('Do not navigate to /login for non-guest users', async ({ page }) => {
await authPage.login(testUser.email, testUser.password);
await page.waitForURL('/');
await page.goto('/login');
await expect(page).toHaveURL('/');
});
});
test.describe('Entitlements', () => {
let chatPage: ChatPage;
test.beforeEach(async ({ page }) => {
chatPage = new ChatPage(page);
});
test('Guest user cannot send more than 20 messages/day', async () => {
await chatPage.createNewChat();
for (let i = 0; i <= 20; i++) {
await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.isGenerationComplete();
}
await chatPage.sendUserMessage('Why is the sky blue?');
await chatPage.expectToastToContain(
'You have exceeded your maximum number of messages for the day! Please try again later.',
);
});
});

View file

@ -1,34 +1,51 @@
import { expect as baseExpect, test as baseTest } from '@playwright/test'; import { expect as baseExpect, test as baseTest } from '@playwright/test';
import { createAuthenticatedContext, type UserContext } from './auth-helper'; import { createAuthenticatedContext, type UserContext } from './helpers';
import { getUnixTime } from 'date-fns';
interface Fixtures { interface Fixtures {
adaContext: UserContext; adaContext: UserContext;
babbageContext: UserContext; babbageContext: UserContext;
curieContext: UserContext;
} }
export const test = baseTest.extend<any, Fixtures>({ export const test = baseTest.extend<any, Fixtures>({
adaContext: [ adaContext: [
async ({ browser }, use) => { async ({ browser }, use, workerInfo) => {
const ada = await createAuthenticatedContext({ const ada = await createAuthenticatedContext({
browser, browser,
name: 'ada', name: `ada-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
}); });
await use(ada); await use(ada);
await ada.context.close(); await ada.context.close();
}, },
{ scope: 'worker' }, { scope: 'worker' },
], ],
babbageContext: [ babbageContext: [
async ({ browser }, use) => { async ({ browser }, use, workerInfo) => {
const babbage = await createAuthenticatedContext({ const babbage = await createAuthenticatedContext({
browser, browser,
name: 'babbage', name: `babbage-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
}); });
await use(babbage); await use(babbage);
await babbage.context.close(); await babbage.context.close();
}, },
{ scope: 'worker' }, { scope: 'worker' },
], ],
curieContext: [
async ({ browser }, use, workerInfo) => {
const curie = await createAuthenticatedContext({
browser,
name: `curie-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
chatModel: 'chat-model-reasoning',
});
await use(curie);
await curie.context.close();
},
{ scope: 'worker' },
],
}); });
export const expect = baseExpect; export const expect = baseExpect;

View file

@ -8,6 +8,7 @@ import {
type Page, type Page,
} from '@playwright/test'; } from '@playwright/test';
import { generateId } from 'ai'; import { generateId } from 'ai';
import { ChatPage } from './pages/chat';
import { getUnixTime } from 'date-fns'; import { getUnixTime } from 'date-fns';
export type UserContext = { export type UserContext = {
@ -19,22 +20,24 @@ export type UserContext = {
export async function createAuthenticatedContext({ export async function createAuthenticatedContext({
browser, browser,
name, name,
chatModel = 'chat-model',
}: { }: {
browser: Browser; browser: Browser;
name: string; name: string;
chatModel?: 'chat-model' | 'chat-model-reasoning';
}): Promise<UserContext> { }): Promise<UserContext> {
const authDir = path.join(__dirname, '../playwright/.auth'); const directory = path.join(__dirname, '../playwright/.sessions');
if (!fs.existsSync(authDir)) { if (!fs.existsSync(directory)) {
fs.mkdirSync(authDir, { recursive: true }); fs.mkdirSync(directory, { recursive: true });
} }
const storageFile = path.join(authDir, `${name}.json`); const storageFile = path.join(directory, `${name}.json`);
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
const email = `test-${name}-${getUnixTime(new Date())}@playwright.com`; const email = `test-${name}@playwright.com`;
const password = generateId(16); const password = generateId(16);
await page.goto('http://localhost:3000/register'); await page.goto('http://localhost:3000/register');
@ -48,6 +51,12 @@ export async function createAuthenticatedContext({
'Account created successfully!', 'Account created successfully!',
); );
const chatPage = new ChatPage(page);
await chatPage.createNewChat();
await chatPage.chooseModelFromSelector('chat-model-reasoning');
await expect(chatPage.getSelectedModel()).resolves.toEqual('Reasoning model');
await page.waitForTimeout(1000);
await context.storageState({ path: storageFile }); await context.storageState({ path: storageFile });
await page.close(); await page.close();
@ -60,3 +69,13 @@ export async function createAuthenticatedContext({
request: newContext.request, request: newContext.request,
}; };
} }
export function generateRandomTestUser() {
const email = `test-${getUnixTime(new Date())}@playwright.com`;
const password = generateId(16);
return {
email,
password,
};
}

65
tests/pages/auth.ts Normal file
View file

@ -0,0 +1,65 @@
import type { Page } from '@playwright/test';
import { expect } from '../fixtures';
export class AuthPage {
constructor(private page: Page) {}
async gotoLogin() {
await this.page.goto('/login');
await expect(this.page.getByRole('heading')).toContainText('Sign In');
}
async gotoRegister() {
await this.page.goto('/register');
await expect(this.page.getByRole('heading')).toContainText('Sign Up');
}
async register(email: string, password: string) {
await this.gotoRegister();
await this.page.getByPlaceholder('user@acme.com').click();
await this.page.getByPlaceholder('user@acme.com').fill(email);
await this.page.getByLabel('Password').click();
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign Up' }).click();
}
async login(email: string, password: string) {
await this.gotoLogin();
await this.page.getByPlaceholder('user@acme.com').click();
await this.page.getByPlaceholder('user@acme.com').fill(email);
await this.page.getByLabel('Password').click();
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign In' }).click();
}
async logout(email: string, password: string) {
await this.login(email, password);
await this.page.waitForURL('/');
await this.openSidebar();
const userNavButton = this.page.getByTestId('user-nav-button');
await expect(userNavButton).toBeVisible();
await userNavButton.click();
const userNavMenu = this.page.getByTestId('user-nav-menu');
await expect(userNavMenu).toBeVisible();
const authMenuItem = this.page.getByTestId('user-nav-item-auth');
await expect(authMenuItem).toContainText('Sign out');
await authMenuItem.click();
const userEmail = this.page.getByTestId('user-email');
await expect(userEmail).toContainText('Guest');
}
async expectToastToContain(text: string) {
await expect(this.page.getByTestId('toast')).toContainText(text);
}
async openSidebar() {
const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button');
await sidebarToggleButton.click();
}
}

View file

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'node:fs';
import path from 'path'; import path from 'node:path';
import { chatModels } from '@/lib/ai/models'; import { chatModels } from '@/lib/ai/models';
import { expect, Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
export class ChatPage { export class ChatPage {
constructor(private page: Page) {} constructor(private page: Page) {}
@ -179,4 +179,13 @@ export class ChatPage {
}, },
}; };
} }
async expectToastToContain(text: string) {
await expect(this.page.getByTestId('toast')).toContainText(text);
}
async openSideBar() {
const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button');
await sidebarToggleButton.click();
}
} }

View file

@ -21,7 +21,7 @@ test.describe
test('Ada can invoke chat generation', async ({ adaContext }) => { test('Ada can invoke chat generation', async ({ adaContext }) => {
const chatId = generateUUID(); const chatId = generateUUID();
const response = await adaContext.request.post('api/chat', { const response = await adaContext.request.post('/api/chat', {
data: { data: {
id: chatId, id: chatId,
messages: TEST_PROMPTS.SKY.MESSAGES, messages: TEST_PROMPTS.SKY.MESSAGES,
@ -44,7 +44,7 @@ test.describe
}) => { }) => {
const [chatId] = chatIdsCreatedByAda; const [chatId] = chatIdsCreatedByAda;
const response = await babbageContext.request.post('api/chat', { const response = await babbageContext.request.post('/api/chat', {
data: { data: {
id: chatId, id: chatId,
messages: TEST_PROMPTS.GRASS.MESSAGES, messages: TEST_PROMPTS.GRASS.MESSAGES,
@ -61,7 +61,7 @@ test.describe
const [chatId] = chatIdsCreatedByAda; const [chatId] = chatIdsCreatedByAda;
const response = await babbageContext.request.delete( const response = await babbageContext.request.delete(
`api/chat?id=${chatId}`, `/api/chat?id=${chatId}`,
); );
expect(response.status()).toBe(403); expect(response.status()).toBe(403);
@ -72,7 +72,9 @@ test.describe
test('Ada can delete her own chat', async ({ adaContext }) => { test('Ada can delete her own chat', async ({ adaContext }) => {
const [chatId] = chatIdsCreatedByAda; const [chatId] = chatIdsCreatedByAda;
const response = await adaContext.request.delete(`api/chat?id=${chatId}`); const response = await adaContext.request.delete(
`/api/chat?id=${chatId}`,
);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
const deletedChat = await response.json(); const deletedChat = await response.json();