Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
91 changes: 91 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: AllClass CI
run-name: AllClass CI • ${{ github.ref_name }} • by ${{ github.actor }}

on:
push:
branches:
- main
- dev
- feat/**
pull_request:
branches:
- main
- dev
- feat/**

jobs:
lint_build:
name: "🔧 Lint & Build"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: "========== 🔧 Lint & Build • START =========="
run: echo "========== 🔧 Lint & Build • START =========="

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Lint
run: yarn lint

- name: Build
run: yarn build
env:
NEXT_TELEMETRY_DISABLED: 1
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

- name: "========== 🔧 Lint & Build • END =========="
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
echo "========== 🔧 Lint & Build • END (status: ${JOB_STATUS}) =========="

e2e:
name: "🧪 AllClass E2E Test"
needs: lint_build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: "========== 🧪 AllClass E2E • START =========="
run: echo "========== 🧪 AllClass E2E • START =========="

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Cypress run
uses: cypress-io/github-action@v6
with:
build: yarn build
start: yarn start
wait-on: 'http://localhost:3000'
browser: chrome
env:
CI: true
NEXT_TELEMETRY_DISABLED: 1
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
CYPRESS_TEST_USER_ID: ${{ secrets.CYPRESS_TEST_USER_ID }}
CYPRESS_TEST_USER_PW: ${{ secrets.CYPRESS_TEST_USER_PW }}

- name: "========== 🧪 AllClass E2E • END =========="
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
echo "========== 🧪 AllClass E2E • END (status: ${JOB_STATUS}) =========="
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
# cypress local secrets
cypress.env.json

# vercel
.vercel
Expand Down
8 changes: 3 additions & 5 deletions app/[universityName]/[lectureId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { ReviewCardListSkeleton } from '@/domains/review';
import { LectureInfoSkeleton } from '@/domains/lecture';
import { LectureInfoServer } from '@/domains/lecture/server/components/LectureInfoServer';
import { ReviewListServer } from '@/domains/review/server/components/ReviewListServer';
import { ReviewCardListSkeleton, ReviewListServer } from '@/domains/review';
import { LectureInfoSkeleton, LectureInfoServer } from '@/domains/lecture';
import { Suspense } from 'react';
import styles from '@/styles/global.module.css';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createQueryClient, prefetchLectureRating, prefetchAllSortOptions } from '@/utils';
import { universityNames } from '@/constants';
import { getLectureListStatic } from '@/lib';
import styles from '@/styles/global.module.css';

export async function generateStaticParams() {
const allParams = await Promise.allSettled(
Expand Down
21 changes: 20 additions & 1 deletion app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
try {
const { universityName, action } = await request.json();
const { universityName, action, lectureId, userNumber } = await request.json();

if (!universityName) {
return NextResponse.json({ message: 'University name is required' }, { status: 400 });
Expand All @@ -14,6 +14,23 @@ export async function POST(request: NextRequest) {
case 'lecture_removed':
case 'lecture_modified':
await revalidateTag(`lectures-${universityName}`);
if (lectureId) {
await revalidateTag(`lecture-info-${universityName}-${lectureId}`);
await revalidateTag(`reviews-${lectureId}`);
}
break;

case 'review_added':
case 'review_updated':
case 'review_deleted':
if (lectureId) {
await revalidateTag(`reviews-${lectureId}`);
await revalidateTag(`lecture-info-${universityName}-${lectureId}`);
}
if (userNumber) {
await revalidateTag(`my-reviews-${userNumber}`);
await revalidateTag(`my-lectures-${userNumber}`);
}
break;

case 'university_added':
Expand All @@ -29,6 +46,8 @@ export async function POST(request: NextRequest) {
timestamp: new Date().toISOString(),
action,
universityName,
lectureId,
userNumber,
});
} catch (error) {
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
Expand Down
8 changes: 3 additions & 5 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,9 @@ export default function RootLayout({
return (
<html lang="ko">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<AppProviders>
<AuthHydrationProvider>
<div>{children}</div>
</AuthHydrationProvider>
</AppProviders>
<AuthHydrationProvider>
<div>{children}</div>
</AuthHydrationProvider>
</body>
</html>
);
Expand Down
7 changes: 6 additions & 1 deletion app/providers/AuthHydrationProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { hydrateAuthState } from '@/utils/authHydration';
import AppProviders from './AppProviders';

interface AuthHydrationProviderProps {
children: React.ReactNode;
Expand All @@ -10,5 +11,9 @@ export default async function AuthHydrationProvider({ children }: AuthHydrationP

await hydrateAuthState(queryClient);

return <HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary>;
return (
<AppProviders>
<HydrationBoundary state={dehydrate(queryClient)}>{children}</HydrationBoundary>
</AppProviders>
);
}
2 changes: 1 addition & 1 deletion components/common/auth/LoginTriggerWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useLoginTrigger } from '@/hooks/useLoginTrigger';
import { useLoginTrigger } from './hooks/useLoginTrigger';
import { LoadingSpinner } from '../loading/LoadingSpinner';

function LoginTrigger() {
Expand Down
File renamed without changes.
7 changes: 6 additions & 1 deletion components/common/starRating/InteractiveStarRating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ export default function InteractiveStarRating({
const displayRating = hoverRating || rating;

return (
<div className={`${styles.container} ${styles[size]}`} onMouseLeave={handleMouseLeave}>
<div
className={`${styles.container} ${styles[size]}`}
onMouseLeave={handleMouseLeave}
data-test="star-rating"
>
<svg width="0" height="0" style={{ position: 'absolute' }}>
<defs>
<linearGradient id="halfStar" x1="0%" y1="0%" x2="100%" y2="0%">
Expand All @@ -68,6 +72,7 @@ export default function InteractiveStarRating({
onMouseEnter={(event) => handleMouseEnter(star, event)}
onClick={(event) => handleClick(star, event)}
disabled={disabled}
data-test={`star-${star}`}
>
<Star
size={size === 'small' ? 20 : size === 'medium' ? 28 : 32}
Expand Down
12 changes: 3 additions & 9 deletions components/ui/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,21 @@

import styles from './Button.module.css';

interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'primary' | 'secondary';
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
className?: string;
}

export default function Button({
children,
onClick,
variant = 'default',
disabled = false,
type = 'button',
className = '',
...rest
}: ButtonProps) {
const buttonClass = `${styles.button} ${styles[variant]} ${className}`.trim();

return (
<button type={type} className={buttonClass} onClick={onClick} disabled={disabled}>
<button {...rest} type={rest.type ?? 'button'} className={buttonClass}>
{children}
</button>
);
Expand Down
6 changes: 3 additions & 3 deletions components/ui/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function Header({ showDropdown = false }: HeaderProps) {
</section>
<section className={`${styles.rightSection} ${!showDropdown ? styles.alwaysVisible : ''}`}>
{isLoggedIn ? (
<Button variant="default" onClick={handleMyPageClick}>
<Button variant="default" onClick={handleMyPageClick} data-test="open-mypage">
마이페이지
</Button>
) : (
Expand All @@ -60,11 +60,11 @@ export default function Header({ showDropdown = false }: HeaderProps) {
</Button>
)}
{isLoggedIn ? (
<Button variant="default" onClick={handleLogoutClick}>
<Button variant="default" onClick={handleLogoutClick} data-test="logout">
로그아웃
</Button>
) : (
<Button variant="default" onClick={handleLoginClick}>
<Button variant="default" onClick={handleLoginClick} data-test="open-login">
로그인
</Button>
)}
Expand Down
4 changes: 3 additions & 1 deletion components/ui/universityList/DropdownUniversityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export default function DropdownUniversityList() {

return (
<div className={styles.centerDropdown} ref={dropdownRef} onMouseEnter={handleDropdownOpen}>
<button className={styles.dropdownTrigger}>대학교 선택</button>
<button className={styles.dropdownTrigger} data-test="univ-switch">
대학교 선택
</button>
<div
className={`${styles.dropdownMenu} ${isDropdownOpen ? styles.open : ''}`}
onMouseLeave={handleDropdownMouseLeave}
Expand Down
2 changes: 2 additions & 0 deletions components/ui/universityList/UniversityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const UniversityList = memo(function UniversityList() {
className={`${styles.universityCard} university-card`}
key={university.slug}
href={href}
data-test="univ-option"
data-slug={university.slug}
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
if (isCurrent) {
e.preventDefault();
Expand Down
5 changes: 0 additions & 5 deletions cypress/e2e/home.cy.ts

This file was deleted.

64 changes: 64 additions & 0 deletions cypress/e2e/tier1-critical/01-auth-and-review.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
describe('사용자는 로그인을하고 자신이 들은 강의에 리뷰를 작성할 수 있다.', () => {
it('사용자는 헤더에 로그인 버튼을 통해 로그인할 수 있다.', () => {
cy.clearCookies();

cy.uiLogin();

cy.get('header').contains('button', '로그아웃').should('be.visible');
});

it('사용자는 자신이 수강한 강의를 찾고 리뷰를 작성할 수 있다.', () => {
cy.uiLogin();
cy.visit(encodeURI('/동서대학교'));

cy.contains('[data-test="lecture-card"] h3', '데이터마이닝', { timeout: 15000 })
.scrollIntoView()
.should('be.visible')
.then(($h3) => {
cy.wrap($h3).closest('a').click({ force: true });
});
cy.byTest('lecture-detail').should('exist');

cy.byTest('write-review').click();
cy.byTest('write-review-form').should('exist');
cy.byTest('review-title-input').type('데이터마이닝 수업 후기');
cy.byTest('review-content-input').type(
'실습 위주로 진행되어 이해가 쉬웠고, 과제량이 적당했습니다. 추천합니다.'
);
cy.byTest('star-5').click({ force: true });

cy.window().then((win) => {
cy.stub(win, 'alert').as('alert');
});
cy.byTest('write-submit').click({ force: true });

cy.get('@alert').should('have.been.called');
cy.byTest('review-list').should('exist');
cy.byTest('review-item').should('exist');

cy.reload();
cy.byTest('review-list').should('exist');
cy.byTest('review-item').should('exist');
});

it('사용자는 수강하지 않은 강의에 리뷰를 작성할 수 없다.', () => {
cy.uiLogin();
cy.visit(encodeURI('/동서대학교'));
cy.byTest('lecture-card').first().click();
cy.byTest('lecture-detail').should('exist');

cy.byTest('write-review').click();
cy.byTest('write-review-form').should('exist');
cy.byTest('review-title-input').type('후기 작성 테스트');
cy.byTest('review-content-input').type(
'수강하지 않은 강의에 대한 리뷰 작성 시도가 차단되는지 확인합니다.'
);
cy.byTest('star-5').click({ force: true });
cy.window().then((win) => {
cy.stub(win, 'alert').as('alert');
});
cy.byTest('write-submit').click({ force: true });

cy.get('@alert').should('have.been.called');
});
});
Loading