feat: 프로젝트 API Bearer 토큰 인증 + E2E 테스트 스크립트 + 크몽 마케팅 이미지
- app/api/projects, link/route: Cookie + Bearer 토큰 이중 인증 지원 (E2E 테스트 대응) - app/mypage: 로또 기록 탭 제거, 구독 빈 상태 프롬프트 서비스로 변경 - scripts/test-flow.mjs: 견적서 발송→연결→마일스톤 진행 E2E 테스트 스크립트 - supabase/migrations/003: quotes RLS 비활성화 (관리자 서버 전용 접근) - marketing/kmong-images: 크몽 서비스 A 상세 이미지 5장 (HTML 스크린샷용) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
405
scripts/test-flow.mjs
Normal file
405
scripts/test-flow.mjs
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* 프로젝트 추적 시스템 E2E 테스트 스크립트
|
||||
* 실행: node scripts/test-flow.mjs
|
||||
*
|
||||
* 테스트 흐름:
|
||||
* 1. 테스트 계정 생성 (Supabase Auth)
|
||||
* 2. 관리자 로그인
|
||||
* 3. 관리자 → 테스트 견적서 생성 + 발송
|
||||
* 4. 테스트 유저 → 견적서 코드로 마이페이지 연결
|
||||
* 5. 관리자 → 기본 7단계 마일스톤 초기화
|
||||
* 6. 관리자 → 단계 진행 (1~3단계 완료)
|
||||
* 7. 테스트 유저 → 마이페이지에서 진행 상황 확인 (브라우저)
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
const SUPABASE_URL = 'https://avickbbhyhlnqbbqfzws.supabase.co';
|
||||
const SUPABASE_ANON_KEY =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImF2aWNrYmJoeWhsbnFiYnFmendzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MjY0OTQsImV4cCI6MjA4NjQwMjQ5NH0.CFJCWlZpVzv3FQohhVuQLS8GbkvJrc7T7zDwltWuVZw';
|
||||
const ADMIN_ID = 'jaengseung_admin';
|
||||
const ADMIN_PASSWORD = 'JaengSeung@Admin2026!';
|
||||
|
||||
// 테스트 계정 정보
|
||||
const TEST_EMAIL = `testuser_${Date.now()}@test-jaengseung.com`;
|
||||
const TEST_PASSWORD = 'Test@2026!';
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// 유틸
|
||||
// ───────────────────────────────────────────
|
||||
let _adminCookie = '';
|
||||
|
||||
function log(step, msg, data) {
|
||||
const prefix = `\n[STEP ${step}]`;
|
||||
console.log(`${prefix} ${msg}`);
|
||||
if (data) console.log(' →', JSON.stringify(data, null, 2).slice(0, 400));
|
||||
}
|
||||
|
||||
function ok(label, value) {
|
||||
console.log(` ✅ ${label}: ${value}`);
|
||||
}
|
||||
|
||||
function fail(label, err) {
|
||||
console.error(` ❌ ${label}: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function adminFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: _adminCookie,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||
return { status: res.status, data: json, headers: res.headers };
|
||||
}
|
||||
|
||||
async function userFetch(path, accessToken, options = {}) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `sb-avickbbhyhlnqbbqfzws-auth-token=${accessToken}`,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||
return { status: res.status, data: json };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 1: 테스트 유저 생성
|
||||
// ───────────────────────────────────────────
|
||||
async function step1_createUser() {
|
||||
log(1, '테스트 계정 생성');
|
||||
console.log(` 이메일: ${TEST_EMAIL}`);
|
||||
console.log(` 비밀번호: ${TEST_PASSWORD}`);
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
options: { data: { name: '테스트 고객' } },
|
||||
});
|
||||
|
||||
if (error) fail('회원가입', error.message);
|
||||
|
||||
const user = data?.user;
|
||||
if (!user) fail('회원가입', '유저 데이터 없음');
|
||||
|
||||
ok('유저 ID', user.id);
|
||||
ok('이메일 확인 필요 여부', user.email_confirmed_at ? '이미 확인됨' : '미확인 (로컬 테스트는 통과)');
|
||||
|
||||
// 로그인으로 세션 획득
|
||||
const { data: loginData, error: loginErr } = await supabase.auth.signInWithPassword({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
});
|
||||
|
||||
if (loginErr) fail('유저 로그인', loginErr.message);
|
||||
|
||||
const session = loginData?.session;
|
||||
if (!session) fail('유저 로그인', '세션 없음 (이메일 인증 필요)');
|
||||
|
||||
ok('Access Token 획득', session.access_token.slice(0, 30) + '...');
|
||||
return { userId: user.id, session };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 2: 관리자 로그인
|
||||
// ───────────────────────────────────────────
|
||||
async function step2_adminLogin() {
|
||||
log(2, '관리자 로그인');
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: ADMIN_ID, password: ADMIN_PASSWORD }),
|
||||
});
|
||||
|
||||
if (res.status !== 200) fail('관리자 로그인', `HTTP ${res.status}`);
|
||||
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (!setCookie) fail('관리자 로그인', 'Set-Cookie 헤더 없음');
|
||||
|
||||
_adminCookie = setCookie.split(';')[0]; // admin_token=xxx
|
||||
ok('Admin Cookie', _adminCookie.slice(0, 50) + '...');
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 3: 견적서 생성
|
||||
// ───────────────────────────────────────────
|
||||
async function step3_createQuote() {
|
||||
log(3, '견적서 생성');
|
||||
|
||||
const { status, data } = await adminFetch('/api/admin/quotes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: '테스트 홈페이지 제작 견적서',
|
||||
client_name: '테스트 고객',
|
||||
client_email: TEST_EMAIL,
|
||||
client_phone: '010-1234-5678',
|
||||
valid_until: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
name: '기업 홈페이지 제작',
|
||||
description: 'Next.js 기반 반응형 홈페이지',
|
||||
unitPrice: 1500000,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
id: 'item2',
|
||||
name: 'SEO 최적화',
|
||||
description: '메타태그 + 사이트맵 설정',
|
||||
unitPrice: 300000,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
notes: '테스트 플로우용 견적서입니다. 납기 2주 예정.',
|
||||
}),
|
||||
});
|
||||
|
||||
if (status !== 201) fail('견적서 생성', `HTTP ${status} - ${JSON.stringify(data)}`);
|
||||
|
||||
const quote = data.quote;
|
||||
ok('견적서 ID', quote.id);
|
||||
ok('public_token', quote.public_token);
|
||||
ok('상태', quote.status);
|
||||
return quote;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 4: 견적서 상태를 'sent'로 변경
|
||||
// ───────────────────────────────────────────
|
||||
async function step4_sendQuote(quoteId) {
|
||||
log(4, "견적서 상태 → 'sent' (발송)");
|
||||
|
||||
const { status, data } = await adminFetch(`/api/admin/quotes/${quoteId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'sent' }),
|
||||
});
|
||||
|
||||
if (status !== 200) fail('견적서 발송', `HTTP ${status} - ${JSON.stringify(data)}`);
|
||||
|
||||
ok('새 상태', data.quote?.status);
|
||||
return data.quote;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 5: 테스트 유저가 견적서 코드 연결
|
||||
// ───────────────────────────────────────────
|
||||
async function step5_linkQuote(publicToken, session) {
|
||||
log(5, '테스트 유저 → 견적서 코드 연결 (마이페이지)');
|
||||
|
||||
// 유저 세션 쿠키로 API 호출
|
||||
const res = await fetch(`${BASE_URL}/api/projects/link`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ token: publicToken }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log(` HTTP ${res.status}:`, JSON.stringify(data));
|
||||
|
||||
if (res.status === 200 && data.success) {
|
||||
ok('연결 성공', `quoteId=${data.quoteId}`);
|
||||
return true;
|
||||
} else {
|
||||
// RLS 제한으로 실패할 수 있음 — 안내 메시지 출력
|
||||
console.warn('\n ⚠️ 연결 API가 실패했습니다.');
|
||||
console.warn(' 원인: SUPABASE_SERVICE_ROLE_KEY 가 비어 있어 RLS 우회 불가');
|
||||
console.warn(' 해결: 아래 STEP 5b 안내 따라 서비스 롤 키를 추가하거나,');
|
||||
console.warn(' 브라우저에서 직접 마이페이지 > "견적서 코드 입력" 테스트하세요.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 6: 기본 7단계 마일스톤 초기화
|
||||
// ───────────────────────────────────────────
|
||||
async function step6_initMilestones(quoteId) {
|
||||
log(6, '기본 7단계 마일스톤 초기화');
|
||||
|
||||
const { status, data } = await adminFetch('/api/admin/milestones', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ useDefaults: true, quoteId }),
|
||||
});
|
||||
|
||||
if (status !== 200 && status !== 201) fail('마일스톤 초기화', `HTTP ${status} - ${JSON.stringify(data)}`);
|
||||
|
||||
ok('마일스톤 생성 수', data.milestones?.length ?? 0);
|
||||
data.milestones?.forEach((m) => console.log(` ${m.step_number}. [${m.status}] ${m.title}`));
|
||||
return data.milestones;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 7: 마일스톤 단계 업데이트
|
||||
// ───────────────────────────────────────────
|
||||
async function step7_updateMilestones(milestones) {
|
||||
log(7, '단계 진행 시뮬레이션 (1·2·3단계 완료, 4단계 진행중)');
|
||||
|
||||
const updates = [
|
||||
{ id: milestones[0].id, status: 'completed', note: '고객 의뢰 접수 완료. 2주 일정 확인.' },
|
||||
{ id: milestones[1].id, status: 'completed', note: '계약서 서명 및 선금 50% 입금 완료.' },
|
||||
{ id: milestones[2].id, status: 'completed', note: '와이어프레임 v1 전달. 고객 승인.' },
|
||||
{ id: milestones[3].id, status: 'in_progress', note: '디자인 시안 작업 중 (예상 3일).' },
|
||||
];
|
||||
|
||||
for (const u of updates) {
|
||||
const { status, data } = await adminFetch(`/api/admin/milestones/${u.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: u.status, note: u.note }),
|
||||
});
|
||||
const m = milestones.find((x) => x.id === u.id);
|
||||
if (status === 200) {
|
||||
ok(`단계 ${m?.step_number} (${m?.title})`, `→ ${u.status}`);
|
||||
} else {
|
||||
console.warn(` ⚠️ 단계 업데이트 실패: ${JSON.stringify(data)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// STEP 8: 유저 마이페이지 API 검증
|
||||
// ───────────────────────────────────────────
|
||||
async function step8_verifyUserView(session) {
|
||||
log(8, '유저 마이페이지 API 확인');
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log(` HTTP ${res.status}:`, JSON.stringify(data).slice(0, 500));
|
||||
|
||||
if (res.status === 200 && data.projects?.length > 0) {
|
||||
const p = data.projects[0];
|
||||
ok('프로젝트 제목', p.title);
|
||||
ok('상태', p.status);
|
||||
ok('마일스톤 수', p.milestones?.length ?? 0);
|
||||
const done = p.milestones?.filter((m) => m.status === 'completed').length ?? 0;
|
||||
const inProg = p.milestones?.filter((m) => m.status === 'in_progress').length ?? 0;
|
||||
ok('완료 단계', done);
|
||||
ok('진행중 단계', inProg);
|
||||
} else {
|
||||
console.warn(' ⚠️ 프로젝트가 없거나 연결 실패. 브라우저 직접 확인 필요.');
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// 최종 안내 출력
|
||||
// ───────────────────────────────────────────
|
||||
function printBrowserGuide(quote, email, password, linked) {
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
console.log('📋 브라우저 확인 가이드');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
console.log(`
|
||||
[관리자 화면]
|
||||
URL: http://localhost:3000/admin/login
|
||||
ID : jaengseung_admin
|
||||
PW : JaengSeung@Admin2026!
|
||||
|
||||
→ 로그인 후 견적서 목록에서 아래 견적서 클릭:
|
||||
"${quote.title}" (${quote.id.slice(0, 8)}...)
|
||||
→ "진행 단계" 탭에서 마일스톤 상태 확인
|
||||
→ 단계 상태 변경 → 저장 테스트
|
||||
|
||||
[고객(테스트 유저) 화면]
|
||||
URL : http://localhost:3000/login
|
||||
이메일: ${email}
|
||||
비밀번호: ${password}
|
||||
|
||||
→ 로그인 후 마이페이지 → "프로젝트 현황" 탭
|
||||
`);
|
||||
|
||||
if (!linked) {
|
||||
console.log(` ⚠️ 자동 연결이 안 된 경우:
|
||||
→ 마이페이지 아래 "견적서 코드 입력" 박스에 아래 코드 입력:
|
||||
${quote.public_token}
|
||||
`);
|
||||
} else {
|
||||
console.log(` ✅ 견적서가 이미 연결됨. 바로 "프로젝트 현황" 탭 확인 가능.
|
||||
`);
|
||||
}
|
||||
|
||||
console.log(`[확인 포인트]
|
||||
- 진행률 막대 (완료 3/7 → 약 43%)
|
||||
- 현재 진행 단계 "디자인 시안" 강조 표시
|
||||
- 완료 단계에 ✓ 체크 아이콘 + 날짜
|
||||
- 메모 내용 표시
|
||||
|
||||
[추가 테스트]
|
||||
1. 관리자에서 "4단계 완료", "5단계 진행중"으로 변경
|
||||
2. 새로고침 없이 마이페이지 탭 재진입 → 즉시 반영 확인
|
||||
3. 최종 납품 완료(7단계)로 변경 → 상태 배지 변경 확인
|
||||
`);
|
||||
console.log('═'.repeat(60));
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// 메인
|
||||
// ───────────────────────────────────────────
|
||||
(async () => {
|
||||
console.log('\n🚀 쟁승메이드 프로젝트 추적 시스템 E2E 테스트 시작\n');
|
||||
|
||||
try {
|
||||
// 1. 테스트 유저 생성
|
||||
const { userId, session } = await step1_createUser().catch(async () => {
|
||||
// 이미 존재하면 로그인만
|
||||
console.log(' ℹ️ 이미 존재하는 계정으로 로그인 시도...');
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
});
|
||||
if (error) fail('유저 로그인', error.message);
|
||||
return { userId: data.user.id, session: data.session };
|
||||
});
|
||||
|
||||
// 2. 관리자 로그인
|
||||
await step2_adminLogin();
|
||||
|
||||
// 3. 견적서 생성
|
||||
const quote = await step3_createQuote();
|
||||
|
||||
// 4. 견적서 발송
|
||||
await step4_sendQuote(quote.id);
|
||||
|
||||
// 5. 유저가 견적서 연결
|
||||
const linked = await step5_linkQuote(quote.public_token, session);
|
||||
|
||||
// 6. 마일스톤 초기화
|
||||
const milestones = await step6_initMilestones(quote.id);
|
||||
|
||||
// 7. 단계 업데이트
|
||||
if (milestones?.length >= 4) {
|
||||
await step7_updateMilestones(milestones);
|
||||
}
|
||||
|
||||
// 8. 유저 뷰 검증
|
||||
await step8_verifyUserView(session);
|
||||
|
||||
// 최종 안내
|
||||
printBrowserGuide(quote, TEST_EMAIL, TEST_PASSWORD, linked);
|
||||
|
||||
console.log('\n✅ 테스트 스크립트 완료!\n');
|
||||
} catch (err) {
|
||||
console.error('\n❌ 예상치 못한 오류:', err.message ?? err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user