Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sign in/#58 #56

Closed
wants to merge 8 commits into from
1 change: 1 addition & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const config: StorybookConfig = {
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, '../'),
'next/headers': path.resolve(__dirname, '../app/utils/test/__mock__/next/headers.ts'),
};
}
return config;
Expand Down
11 changes: 11 additions & 0 deletions app/(sub-page)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ContentContainer from '@/app/ui/view/atom/content-container';
import SignInForm from '@/app/ui/user/sign-in-form/sign-in-form';

// Refactor: fallback μŠ€μΌˆλ ˆν†€μœΌλ‘œ λŒ€μ²΄
export default function Page() {
return (
<ContentContainer className="md:w-[768px]">
<SignInForm />
</ContentContainer>
);
}
1 change: 1 addition & 0 deletions app/business/api-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const API_PATH = {
parsePDFtoText: `${BASE_URL}/parsePDFtoText`,
takenLectures: `${BASE_URL}/taken-lectures`,
user: `${BASE_URL}/users`,
auth: `${BASE_URL}/auth`,
};
82 changes: 80 additions & 2 deletions app/business/user/user.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,88 @@

import { FormState } from '@/app/ui/view/molecule/form/form-root';
import { API_PATH } from '../api-path';
import { SignUpRequestBody } from './user.type';
import { SignUpRequestBody, SignInRequestBody } from './user.type';
import { httpErrorHandler } from '@/app/utils/http/http-error-handler';
import { BadRequestError } from '@/app/utils/http/http-error';
import { SignUpFormSchema } from './user.validation';
import { SignUpFormSchema, SignInFormSchema, SignInResponseSchema } from './user.validation';
import { cookies } from 'next/headers';
import { z } from 'zod';

function isValidation<T extends z.ZodObject<any>>(data: any, schema: T): data is z.infer<T> {
try {
schema.parse(data);
return true;
} catch (error) {
return false;
}
}

export async function authenticate(prevState: FormState, formData: FormData): Promise<FormState> {
const validatedFields = SignInFormSchema.safeParse({
authId: formData.get('authId'),
password: formData.get('password'),
});

if (!validatedFields.success) {
return {
isSuccess: false,
isFailure: true,
validationError: validatedFields.error.flatten().fieldErrors,
message: '양식에 맞좰 λ‹€μ‹œ μž…λ ₯ν•΄μ£Όμ„Έμš”.',
};
}

const body: SignInRequestBody = {
...validatedFields.data,
};

try {
const response = await fetch(`${API_PATH.auth}/sign-in`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});

const result = await response.json();

httpErrorHandler(response, result);

if (isValidation(result, SignInResponseSchema)) {
cookies().set('accessToken', result.accessToken, {
httpOnly: true,
// secure: process.env.NODE_ENV === 'production',
path: '/',
});
cookies().set('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
});
}
} catch (error) {
if (error instanceof BadRequestError) {
// 잘λͺ»λœ μš”μ²­ 처리 둜직
return {
isSuccess: false,
isFailure: true,
validationError: {},
message: error.message,
};
} else {
// λ‚˜λ¨Έμ§€ μ—λŸ¬λŠ” 더 μƒμœ„ μˆ˜μ€€μ—μ„œ 처리
throw error;
}
}

return {
isSuccess: true,
isFailure: false,
validationError: {},
message: '둜그인 성곡',
};
}

export async function createUser(prevState: FormState, formData: FormData): Promise<FormState> {
const validatedFields = SignUpFormSchema.safeParse({
Expand Down
10 changes: 10 additions & 0 deletions app/business/user/user.type.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
// https://stackoverflow.com/questions/76957592/error-only-async-functions-are-allowed-to-be-exported-in-a-use-server-file
// server action νŒŒμΌμ—μ„œλŠ” async function만 export κ°€λŠ₯

import { SignInResponseSchema } from './user.validation';
import z from 'zod';

export interface SignUpRequestBody {
authId: string;
password: string;
studentNumber: string;
engLv: string;
}

export interface SignInRequestBody {
authId: string;
password: string;
}

export type SignInResponse = z.infer<typeof SignInResponseSchema>;
10 changes: 10 additions & 0 deletions app/business/user/user.validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { z } from 'zod';

export const SignInFormSchema = z.object({
authId: z.string(),
password: z.string(),
});

export const SignInResponseSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
});

export const SignUpFormSchema = z
.object({
authId: z
Expand Down
7 changes: 6 additions & 1 deletion app/mocks/db.mock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TakenLectures } from '../business/lecture/taken-lecture.query';
import { SignUpRequestBody } from '../business/user/user.type';
import { SignUpRequestBody, SignInRequestBody } from '../business/user/user.type';
import { takenLectures } from './data.mock';

interface MockUser {
Expand All @@ -19,6 +19,7 @@ type MockDatabaseAction = {
getTakenLectures: () => TakenLectures[];
getUser: (authId: string) => MockUser | undefined;
createUser: (user: MockUser) => boolean;
signIn: (userData: SignInRequestBody) => boolean;
};

export const mockDatabase: MockDatabaseAction = {
Expand All @@ -31,6 +32,10 @@ export const mockDatabase: MockDatabaseAction = {
mockDatabaseStore.users = [...mockDatabaseStore.users, user];
return true;
},
signIn: (userData: SignInRequestBody) => {
const user = mockDatabaseStore.users.find((u) => u.authId === userData.authId && u.password === userData.password);
return !!user;
},
};

const initialState: MockDatabaseState = {
Expand Down
25 changes: 24 additions & 1 deletion app/mocks/handlers/user-handler.mock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { HttpResponse, http, delay } from 'msw';
import { API_PATH } from '../../business/api-path';
import { mockDatabase } from '../db.mock';
import { SignUpRequestBody } from '@/app/business/user/user.type';
import { SignUpRequestBody, SignInRequestBody, SignInResponse } from '@/app/business/user/user.type';
import { ErrorResponseData } from '@/app/utils/http/http-error-handler';

export const userHandlers = [
http.post<never, SignUpRequestBody, never>(`${API_PATH.user}/sign-up`, async ({ request }) => {
Expand All @@ -16,4 +17,26 @@ export const userHandlers = [

return HttpResponse.json({ status: 200 });
}),

http.post<never, SignInRequestBody, SignInResponse | ErrorResponseData>(
`${API_PATH.auth}/sign-in`,
async ({ request }) => {
const signInData = await request.json();

const isSuccess = mockDatabase.signIn(signInData);
await delay(500);

if (!isSuccess) {
return HttpResponse.json(
{ status: 400, message: '아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.' },
{ status: 400 },
);
}

return HttpResponse.json({
accessToken: 'fake-access-token',
refreshToken: 'fake-refresh-token',
});
},
),
];
73 changes: 73 additions & 0 deletions app/ui/user/sign-in-form/sign-in-form.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react';

import SignInForm from './sign-in-form';

import { userEvent, within, expect, fn, waitFor } from '@storybook/test';
import { resetMockDB, mockDatabase } from '@/app/mocks/db.mock';

const meta = {
title: 'ui/user/SignInForm',
component: SignInForm,
decorators: [
(Story) => (
<div className="w-96">
<Story />
</div>
),
],
args: {
onNext: fn(),
},
} as Meta<typeof SignInForm>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

const beforeEach = () => {
resetMockDB();
mockDatabase.createUser({
authId: 'testtest',
password: 'test1234!',
studentNumber: '60000001',
engLv: 'ENG12',
});
};

export const SuccessSenario: Story = {
play: async ({ args, canvasElement, step }) => {
beforeEach();
const canvas = within(canvasElement);

await step('μ‚¬μš©μžκ°€ 양식에 λ§žμΆ°μ„œ 폼을 μž…λ ₯ν•˜λ©΄', async () => {
await userEvent.type(canvas.getByLabelText('아이디'), 'testtest');
await userEvent.type(canvas.getByLabelText('λΉ„λ°€λ²ˆν˜Έ'), 'test1234!');

await userEvent.click(canvas.getByText('둜그인'));
});

await step('λ‘œκ·ΈμΈμ— μ„±κ³΅ν•œλ‹€', async () => {
await waitFor(() => expect(args.onNext).toHaveBeenCalled());
});
},
};

export const FailureScenarioWithoutUser: Story = {
play: async ({ args, canvasElement, step }) => {
beforeEach();
const canvas = within(canvasElement);

await step('μ‚¬μš©μžκ°€ μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ•„μ΄λ””λ‘œ λ‘œκ·ΈμΈν•˜λ©΄', async () => {
await userEvent.type(canvas.getByLabelText('아이디'), 'test');
await userEvent.type(canvas.getByLabelText('λΉ„λ°€λ²ˆν˜Έ'), 'test1234');

await userEvent.click(canvas.getByText('둜그인'));
});

await step('λ‘œκ·ΈμΈμ— μ‹€νŒ¨ν•œλ‹€', async () => {
await waitFor(() => expect(args.onNext).not.toHaveBeenCalled());
expect(await canvas.findByText('아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.')).toBeInTheDocument();
});
},
};
17 changes: 17 additions & 0 deletions app/ui/user/sign-in-form/sign-in-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';
import Form from '../../view/molecule/form';
import { authenticate } from '@/app/business/user/user.command';

interface SignInFormProps {
onNext?: () => void;
}

export default function SignInForm({ onNext }: SignInFormProps) {
return (
<Form onSuccess={onNext} id="둜그인" action={authenticate}>
<Form.TextInput required={true} label="아이디" id="authId" placeholder="아이디λ₯Ό μž…λ ₯ν•˜μ„Έμš”" />
<Form.PasswordInput required={true} label="λΉ„λ°€λ²ˆν˜Έ" id="password" placeholder="λΉ„λ°€λ²ˆν˜Έλ₯Ό μž…λ ₯ν•˜μ„Έμš”" />
<Form.SubmitButton label="둜그인" position="center" variant="primary" />
</Form>
);
}
6 changes: 6 additions & 0 deletions app/utils/test/__mock__/next/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function cookies() {
return {
set: () => {},
get: () => {},
};
}
Loading