import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import bcrypt from 'bcryptjs'; import express from 'express'; import { initDb } from '../src/db.js'; import { createAdminRouter } from '../src/routes/admin.js'; import { createAuthRouter } from '../src/routes/auth.js'; import { deleteUserAccount } from '../src/services/accountDeletion.js'; test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-')); const dbPath = path.join(tempDir, 'test.db'); process.env.AVATARS_PATH = path.join(tempDir, 'avatars'); const db = initDb(dbPath); try { const oldUserId = Number( db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, email_verified_at ) VALUES (?, ?, ?, ?, datetime('now')) `).run('old@example.com', 'test', 'old-password-hash', 'test').lastInsertRowid, ); db.prepare(` INSERT INTO decks ( share_code, share_type, user_id, series, series_name, name, cards_json, is_public ) VALUES ('OLDDECK1', 'user', ?, 'ua01', '테스트', '기존 덱', '[]', 1) `).run(oldUserId); db.prepare(` INSERT INTO deck_favorites (user_id, share_code) VALUES (?, 'OLDDECK1') `).run(oldUserId); const referenceUserId = Number( db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, email_verified_at ) VALUES (?, ?, ?, ?, datetime('now')) `).run( 'reference@example.com', 'reference', 'reference-password-hash', 'reference', ).lastInsertRowid, ); db.prepare(` INSERT INTO deck_favorites (user_id, share_code) VALUES (?, 'OLDDECK1') `).run(referenceUserId); db.prepare(` INSERT INTO auth_tokens (user_id, type, token_hash, expires_at) VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day')) `).run(oldUserId); db.prepare(` INSERT INTO email_send_logs (user_id, type, month_key, status) VALUES (?, 'verify_email', '2026-06', 'sent') `).run(oldUserId); const deletionResult = deleteUserAccount(db, oldUserId); const newUserId = Number( db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, email_verified_at ) VALUES (?, ?, ?, ?, datetime('now')) `).run('new@example.com', 'test', 'new-password-hash', 'test').lastInsertRowid, ); const oldDeck = db.prepare(` SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1' `).get(); assert.equal(deletionResult.deleted, true); assert.equal(deletionResult.retainedDeckCount, 1); assert.ok(newUserId > oldUserId); assert.equal(oldDeck.userId, null); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count, 0, ); assert.equal( db.prepare(` SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ? AND share_code = 'OLDDECK1' `).get(referenceUserId).count, 1, ); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count, 0, ); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM email_send_logs WHERE user_id = ?').get(oldUserId).count, 0, ); assert.equal( db.prepare(` SELECT COUNT(*) AS count FROM decks WHERE share_code = 'OLDDECK1' AND user_id = ? `).get(newUserId).count, 0, ); } finally { db.close(); fs.rmSync(tempDir, { recursive: true, force: true }); } }); test('이메일 승인·본인 탈퇴·관리자 탈퇴·덱 일괄 삭제 API가 동작한다', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-')); const dbPath = path.join(tempDir, 'test.db'); const db = initDb(dbPath); process.env.JWT_SECRET = 'test-secret-for-admin-email-verification'; const adminPassword = 'admin-password'; const adminUserId = Number( db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, role, email_verified_at ) VALUES (?, ?, ?, ?, 'admin', datetime('now')) `).run( 'admin@example.com', 'admin', bcrypt.hashSync(adminPassword, 4), 'admin', ).lastInsertRowid, ); const targetUserId = Number( db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, email_verified_at ) VALUES (?, ?, ?, ?, NULL) `).run( 'pending@example.com', 'pending', bcrypt.hashSync('pending-password', 4), 'pending', ).lastInsertRowid, ); db.prepare(` INSERT INTO auth_tokens (user_id, type, token_hash, expires_at) VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day')) `).run(targetUserId); db.prepare(` INSERT INTO decks ( share_code, share_type, user_id, series, series_name, name, cards_json, is_public ) VALUES ('PENDING1', 'user', ?, 'ua01', '테스트', '탈퇴 후 보존 덱', '[]', 1) `).run(targetUserId); const forcedUserId = Number( db.prepare(` INSERT INTO users ( email, username, password_hash, display_name, email_verified_at ) VALUES (?, ?, ?, ?, datetime('now')) `).run( 'forced@example.com', 'forced', bcrypt.hashSync('forced-password', 4), 'forced', ).lastInsertRowid, ); db.prepare(` INSERT INTO decks ( share_code, share_type, user_id, series, series_name, name, cards_json, is_public ) VALUES ('BULKDEL1', 'user', ?, 'ua01', '테스트', '일괄 삭제 1', '[]', 1), ('BULKDEL2', 'user', ?, 'ua01', '테스트', '일괄 삭제 2', '[]', 1) `).run(adminUserId, adminUserId); const app = express(); app.use(express.json()); app.use('/api/v1/auth', createAuthRouter(db)); app.use('/api/v1/admin', createAdminRouter(db)); const server = app.listen(0); try { await new Promise((resolve) => server.once('listening', resolve)); const address = server.address(); const baseUrl = `http://127.0.0.1:${address.port}/api/v1`; const loginResponse = await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@example.com', password: adminPassword, }), }); assert.equal(loginResponse.status, 200); const { token } = await loginResponse.json(); const adminSelfDeleteResponse = await fetch(`${baseUrl}/admin/users/${adminUserId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ confirmation: 'DELETE' }), }); assert.equal(adminSelfDeleteResponse.status, 400); const approveResponse = await fetch( `${baseUrl}/admin/users/${targetUserId}/verify-email`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }, ); assert.equal(approveResponse.status, 200); const target = db.prepare(` SELECT email_verified_at AS emailVerifiedAt FROM users WHERE id = ? `).get(targetUserId); const pendingToken = db.prepare(` SELECT used_at AS usedAt FROM auth_tokens WHERE user_id = ? AND type = 'verify_email' `).get(targetUserId); assert.ok(target.emailVerifiedAt); assert.ok(pendingToken.usedAt); const pendingLoginResponse = await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'pending@example.com', password: 'pending-password', }), }); assert.equal(pendingLoginResponse.status, 200); const { token: pendingUserToken } = await pendingLoginResponse.json(); const invalidSelfDeleteResponse = await fetch(`${baseUrl}/auth/me`, { method: 'DELETE', headers: { Authorization: `Bearer ${pendingUserToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ confirmation: 'delete' }), }); assert.equal(invalidSelfDeleteResponse.status, 400); const selfDeleteResponse = await fetch(`${baseUrl}/auth/me`, { method: 'DELETE', headers: { Authorization: `Bearer ${pendingUserToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ confirmation: 'DELETE' }), }); assert.equal(selfDeleteResponse.status, 200); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(targetUserId).count, 0, ); assert.equal( db.prepare(` SELECT user_id AS userId FROM decks WHERE share_code = 'PENDING1' `).get().userId, null, ); const adminDeleteResponse = await fetch(`${baseUrl}/admin/users/${forcedUserId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ confirmation: 'DELETE' }), }); assert.equal(adminDeleteResponse.status, 200); assert.equal( db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(forcedUserId).count, 0, ); const bulkDeleteResponse = await fetch(`${baseUrl}/admin/decks/bulk-delete`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ shareCodes: ['BULKDEL1', 'BULKDEL2'] }), }); assert.equal(bulkDeleteResponse.status, 200); const bulkDeleteResult = await bulkDeleteResponse.json(); assert.equal(bulkDeleteResult.deletedCount, 2); assert.equal( db.prepare(` SELECT COUNT(*) AS count FROM decks WHERE share_code IN ('BULKDEL1', 'BULKDEL2') `).get().count, 0, ); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); db.close(); fs.rmSync(tempDir, { recursive: true, force: true }); } });