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 #58

Merged
merged 14 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
yougyung marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

์ด ํ•จ์ˆ˜๊ฐ€ user.validation.ts๊ฐ€ ์•„๋‹Œ user.command.ts์— ์žˆ๋Š” ์ด์œ ๊ฐ€ ๋”ฐ๋กœ ์žˆ์„๊นŒ์š”??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ์ •ํ™•ํ•˜๊ฒŒ ๋งํ•˜๋ฉด, ์ด ํ•จ์ˆ˜๋Š” user ๋„๋ฉ”์ธ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ๋ชจ๋“  zod ๊ธฐ๋ฐ˜์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์ƒํ™ฉ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์„œ, ์–ด๋””์— ์œ„์น˜์‹œํ‚ฌ์ง€ ๊ณ ๋ฏผ์ด์—ˆ์Šต๋‹ˆ๋‹ค.
  • ์ด ํ•จ์ˆ˜๊ฐ€ zod์— ๊ฐ•ํ•œ ์˜์กด์„ฑ์„ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค๊ณ  ํŒ๋‹จํ•˜์—ฌ, utils ํด๋”์— zod ํด๋”๋ฅผ ๋งŒ๋“ค์–ด์„œ ํ•ด๋‹น ํ•จ์ˆ˜๋ฅผ ๋ฐฐ์น˜ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • 'ํŠน์ • ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ ์ด๋ฆ„์„ ํด๋” ๊ตฌ์กฐ์˜ ์ด๋ฆ„์œผ๋กœ ์‚ฌ์šฉํ•ด๋„ ๋˜๋Š”๊ฐ€?'์— ๋Œ€ํ•œ ๊ณ ๋ฏผ์ด ์žˆ์—ˆ์ง€๋งŒ, util ๊ณ„์ธต์ด ๋ชจ๋“  ๊ณ„์ธต์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ณ„์ธต์ž„์„ ๊ณ ๋ คํ•˜๋ฉด, ์™ธ๋ถ€ ์š”์†Œ์™€ ์ธํ„ฐ๋ž™์…˜์„ ์œ„ํ•œ ์–ด๋Œ‘ํ„ฐ๋ฅผ ์—ฌ๊ธฐ๋‹ค ๋งŒ๋“ค์–ด ๋‘๋Š” ๊ฒƒ์ด ์ƒ๊ฐ๋ณด๋‹ค ๊ดœ์ฐฎ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์ด๋ฏธ shadcn ํด๋”๋„ ์กด์žฌํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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',
yougyung marked this conversation as resolved.
Show resolved Hide resolved
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ํ˜น์‹œ delay๋ฅผ ๊ฑธ์–ด์ฃผ์ง€ ์•Š์œผ๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋‚˜์šฉ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

์•„๋‹ˆ์š”. ์˜ค๋ฅ˜๋Š” ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉฐ, delay๋Š” ์‹ค์ œ๋™์ž‘์„ ๋ชจ์‚ฌํ•˜๊ธฐ ์œ„ํ•œ ์žฅ์น˜์ž…๋‹ˆ๋‹ค.


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>
),
],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
decorators: [
(Story) => (
<div className="w-96">
<Story />
</div>
),
],
decorators: [
(Story) => {
const beforeEach = () => {
resetMockDB();
mockDatabase.createUser({
authId: 'testtest',
password: 'test1234!',
studentNumber: '60000001',
engLv: 'ENG12',
});
};
beforeEach();
return (
<div className="w-96">
<Story />
</div>
);
},
],

์ด๋ ‡๊ฒŒ ํ•ด์„œ ๋ฐ˜๋ณต์ ์ธ ์ฝ”๋“œ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™์•„์š”!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ์˜คํ˜ธ, ์ •๋ง ์ข‹๋„ค์š”! ์Šคํ† ๋ฆฌ๋ถ ํ…Œ์ŠคํŠธ์—์„œ beforeEach๋ฅผ ๋งŒ๋“ค์ง€ ๋ชปํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ํฐ ๋ฌธ์ œ์˜€๋Š”๋ฐ, ํ•ด๊ฒฐ๋œ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.
  • ๋„ˆ๋ฌด ๊ณ ๋งˆ์›Œ์š”

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;
}

Comment on lines +5 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onNext ํ•จ์ˆ˜๋ฅผ props๋กœ ๋ฐ›๋Š” ์ด์œ ๊ฐ€ ๋”ฐ๋กœ ์žˆ์„๊นŒ์š”?!

  • form ์ด ์„ฑ๊ณตํ•˜๋ฉด ํŽ˜์ด์ง€ ์ด๋™์„ ํ• ํ…๋ฐ SignInForm์—์„œ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๋Š” ์ด์œ ๊ฐ€ ๋”ฐ๋กœ ์žˆ์„๊นŒ์š”!?
  • funnel์ด๋ผ๋ฉด onNext ๋ผ๋Š” ์ด๋ฆ„์ด ์ ์ ˆํ•ด๋ณด์ด๋Š”๋ฐ ๊ทธ๊ฒŒ ์•„๋‹ˆ๋ผ๋ฉด ํŽ˜์ด์ง€ ์ด๋™ ์š”๋ก  ๋„ค์ด๋ฐ์ด ๋” ์ ์ ˆํ•˜์ง€ ์•Š์„๊นŒ์šฉ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ์‚ฌ์‹ค ๋กœ๊ทธ์ธ์—๋„ ํผ๋„์„ ์ ์šฉํ•˜๋ ค๊ณ  ํ–ˆ์ง€๋งŒ, ๋ผ์šฐํŒ…์ด ๋” ์ ํ•ฉํ•ด๋ณด์ด๋„ค์š”. ๊ทธ๋ ‡๊ฒŒ ๋˜๋ฉด prop์„ ๋ฐ›์„ ํ•„์š”๊ฐ€ ์—†์–ด ๋ณด์ž…๋‹ˆ๋‹ค.
  • ์ด ๋ถ€๋ถ„์€ ๋‹ค์Œ ๋ธŒ๋žœ์น˜์—์„œ ์ž‘์—…ํ•ด๋„ ๊ดœ์ฐฎ์„๊นŒ์š”? ํ…Œ์ŠคํŠธ ๋ชจํ‚น์ด ๋ณ€๊ฒฝ๋˜์–ด์•ผ ํ•˜๋Š”๋ฐ, ๊ทธ ๋ถ€๋ถ„์ด ๋‹ค์Œ ์ž‘์—… ์‹œ์— ๊ฒฐ์ •๋  ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๋„ต!

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