2026-10-26 11:02:58 +00:00
/**
* 1.0.9 시험: 메일 사용량 정보 제공 API(server/usage.mjs) — 키 생성·교체·삭제(설정 API), 인증(없음/틀림/맞음), IP 허용 목록(IPv4 CIDR·IPv6), 시도 제한,
* 보고서 내용(계정·저장 용량·메일 수·기간 통계·일별 추이·계정별 기간 합계·외부(웹메이커) 계정 수), 도메인 하나/전체, 쿼리(days·accounts·series), Resend 연결 오류 1회 재시도.
* 실행: node test/usage.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import { mkdtempSync , writeFileSync , mkdirSync } from 'node:fs' ;
import { scryptSync , randomBytes } from 'node:crypto' ;
import { tmpdir } from 'node:os' ;
import { join , dirname } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { makeEnv , runner , eq , ok , request , params } from './_helpers.mjs' ;
import { handle } from '../server/api.mjs' ;
import { loadConfig , setupToken } from '../server/config.mjs' ;
import { openTenants } from '../server/domains.mjs' ;
import { setupApi } from '../server/setup.mjs' ;
import { usageApi , authorizeUsage , ipAllowed , parseAllowList , invalidAllowEntries , clientIp , domainUsage , USAGE_FAIL_LIMIT } from '../server/usage.mjs' ;
import { periodBounds } from '../server/limits.mjs' ;
import { createUser } from '../server/auth.mjs' ;
import { resend } from '../server/providers/resend.mjs' ;
import { clearSnapshotCache } from '../server/webmaker.mjs' ;
const { t , done } = runner ();
const HERE = dirname ( fileURLToPath ( import . meta . url )), ROOT = join ( HERE , '..' );
const tmp = ( p = 'arimail-usage-' ) => mkdtempSync ( join ( tmpdir (), p ));
const api = ( env , path , cookie , method , body , headers ) => handle ( env , request ( path , cookie , method , body , headers ), params ( path ));
async function base ( over = {}) {
const dir = tmp ();
const env = await makeEnv ({ DB_FILE : join ( dir , 'mail-gateway.sqlite' ), PUBLIC_HOST : 'mail.arihakdang.com' , ... over });
loadConfig ( env ); return { env , dir };
}
const open = ( env ) => openTenants ( env , { migrationsDir : join ( ROOT , 'migrations' ), log : { log () {}, error () {} } });
const setup = ( env , path , method = 'GET' , body , tenants = null ) => setupApi ( env , new Request ( 'https://mail.arihakdang.com/api/setup/' + path , { method , headers : { 'Content-Type' : 'application/json' }, body : body === undefined ? undefined : JSON . stringify ( body ) }), path . split ( '/' ), { ip : '127.0.0.1' , tenants });
/** /api/usage 호출 흉내(main.mjs 가 하는 것처럼 path 조각·ip·쿼리를 넘김). */
const usage = ( tenants , path = '' , { key , ip = '203.0.113.10' , query = '' } = {}) => usageApi ( tenants , new Request ( 'https://x/api/usage' + path + ( query ? '?' + query : '' ), { headers : key ? { Authorization : 'Bearer ' + key } : {} }), { path : path . split ( '/' ). filter ( Boolean ), ip , query : new URLSearchParams ( query ) });
/** 메일 행 넣기(통계용): direction, 시각, 크기, 메일함. */
async function putMail ( env , { direction , at , size = 1000 , mailbox , state }) {
const id = randomBytes ( 8 ). toString ( 'hex' ), outbound = direction === 'outbound' ;
await env . DB . prepare ( `INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` )
. bind ( id , direction , outbound ? 'sent' : 'inbox' , 'a@b.test' , '["x@y.test"]' , 's' , 'body' , outbound ? 'web' : 'sync' , state || ( outbound ? 'sent' : 'received' ), at , at , at , outbound ? at : null , 'stored' , size , mailbox ). run ();
return id ;
}
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 결정적 시각: 예전에는 UTC 12시 기준이라 UTC 15:00~24:00(= KST 다음 날 0~9시)에 실행하면 "오늘" 이 KST 어제가 되어 오늘 칸이 비었습니다.
// 이제 통계 시간대(Asia/Seoul)의 오늘 0시를 periodBounds 로 구하고 그 날 정오(KST)에서 n 일을 뺍니다 — 어느 시각에 실행해도 같은 칸에 들어갑니다.
const daysAgo = ( n ) => new Date ( Date . parse ( periodBounds ( 'day' , 'Asia/Seoul' ). start ) + 12 * 3600 * 1000 - n * 86400000 ). toISOString ();
2026-10-26 11:02:58 +00:00
await t ( 'IP 허용 목록: 빈 목록은 모두 허용, IPv4 정확히·CIDR, IPv6 정확히·접두사, ::ffff: 로 감싼 IPv4, 잘못된 주소는 거부' , () => {
eq ( parseAllowList ( ' 1.2.3.4, 10.0.0.0/8 ,2001:db8::1 ' ). join ( '|' ), '1.2.3.4|10.0.0.0/8|2001:db8::1' , '파싱' );
ok ( ipAllowed ( '9.9.9.9' , []), '빈 목록' ); ok ( ipAllowed ( '1.2.3.4' , [ '1.2.3.4' ]), '정확히' ); ok ( ! ipAllowed ( '1.2.3.5' , [ '1.2.3.4' ]), '다른 주소' );
ok ( ipAllowed ( '10.20.30.40' , [ '10.0.0.0/8' ]) && ! ipAllowed ( '11.0.0.1' , [ '10.0.0.0/8' ]) && ipAllowed ( '192.168.1.77' , [ '192.168.1.64/26' ]) && ! ipAllowed ( '192.168.1.128' , [ '192.168.1.64/26' ]), 'CIDR' );
ok ( ipAllowed ( '::ffff:10.1.1.1' , [ '10.0.0.0/8' ]), 'IPv6 소켓의 IPv4' ); ok ( ipAllowed ( '2001:db8::1' , [ '2001:DB8::1' ]) && ipAllowed ( '2001:db8:0:0:0:0:0:2' , [ '2001:db8::/32' ]) && ! ipAllowed ( '2001:db9::1' , [ '2001:db8::/32' ]), 'IPv6' );
ok ( ! ipAllowed ( 'not-an-ip' , [ '0.0.0.0/0' ]) && ! ipAllowed ( '' , [ '1.2.3.4' ]), '잘못된 주소' ); ok ( ipAllowed ( '8.8.8.8' , [ '0.0.0.0/0' ]), '/0 은 모두' );
ok ( ipAllowed ( '1.2.3.4' , [ 'garbage' , '1.2.3.4' ]), '목록의 잘못된 항목은 건너뜀' );
eq ( invalidAllowEntries ( '1.2.3.4, 10.0.0.0/8, 2001:db8::/32' ). length , 0 , '유효한 목록' ); eq ( invalidAllowEntries ( '1.2.3.4/33, webmaker.example.com, 10.0.0.0/8/x, 300.1.1.1' ). join ( '|' ), '1.2.3.4/33|webmaker.example.com|10.0.0.0/8/x|300.1.1.1' , '잘못된 항목' ); eq ( invalidAllowEntries ( '' ). length , 0 , '빈 값' );
// 접속 IP: 클라이언트가 넣을 수 있는 헤더(XFF 첫 값, CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)
const H = ( o ) => new Headers ( o );
eq ( clientIp ( H ({ 'x-forwarded-for' : '203.0.113.10, 198.51.100.7' }), '127.0.0.1' ), '198.51.100.7' , 'XFF 마지막(nginx 가 덧붙인 실제 주소)' );
eq ( clientIp ( H ({ 'x-real-ip' : '198.51.100.8' , 'x-forwarded-for' : '203.0.113.10, 1.1.1.1' }), '127.0.0.1' ), '198.51.100.8' , 'X-Real-IP 우선' );
eq ( clientIp ( H ({ 'cf-connecting-ip' : '203.0.113.10' , 'x-forwarded-for' : '9.9.9.9' }), '127.0.0.1' ), '9.9.9.9' , 'CF-Connecting-IP 는 기본적으로 무시' );
eq ( clientIp ( H ({}), '10.0.0.5' ), '10.0.0.5' , '헤더 없으면 소켓 주소' );
});
await t ( '키 관리(설정 API): 키가 없으면 API 404, 만들면 한 번만 돌려주고 설정에는 가린 값, 다시 만들면 옛 키 무효, 삭제하면 다시 404, 추가 도메인에서는 primary_only' , async () => {
const { env } = await base (); const tenants = open ( env ); await tenants . add ({ domain : 'second.test' , adminPassword : 'secondpw1' });
setupToken ( env );
eq (( await usage ( tenants , '' , { key : 'anything' })). status , 404 , '키 없음 → API 꺼짐' ); eq (( await ( await usage ( tenants , '' )). json ()). error , 'usage_api_disabled' , '오류 코드' );
let r = await ( await setup ( env , 'usage-key' , 'POST' , { action : 'generate' }, tenants )). json (); ok ( r . ok && r . key . startsWith ( 'au_' ) && r . key . length > 40 , '키 생성: ' + JSON . stringify ( r ). slice ( 0 , 80 )); eq ( r . endpoint , 'https://mail.arihakdang.com/api/usage' , '주소' );
const key1 = r . key ; eq ( env . USAGE_API_KEY , key1 , 'env 에 적용' );
const cfg = await ( await setup ( env , 'config' , 'GET' , undefined , tenants )). json (); eq ( cfg . present . USAGE_API_KEY , true , 'hidden 항목은 있음/없음만' ); ok ( ! JSON . stringify ( cfg ). includes ( key1 ), '설정 응답에 키 원문 없음' ); eq ( cfg . meta . usageApi . enabled , true , 'meta' ); ok ( cfg . meta . usageApi . keyMasked . includes ( '••••' ), '가린 값' ); ok ( cfg . meta . usageApi . updatedAt , '생성 시각' );
eq (( await usage ( tenants , '' , { key : key1 })). status , 200 , '새 키로 200' );
r = await ( await setup ( env , 'usage-key' , 'POST' , { action : 'generate' }, tenants )). json (); ok ( r . key !== key1 , '교체' ); eq (( await usage ( tenants , '' , { key : key1 })). status , 401 , '옛 키 무효' ); eq (( await usage ( tenants , '' , { key : r . key })). status , 200 , '새 키' );
eq (( await setup ( tenants . byDomain ( 'second.test' ). env , 'usage-key' , 'POST' , { action : 'generate' }, tenants )). status , 409 , '추가 도메인에서는 불가' );
eq (( await setup ( env , 'usage-key' , 'POST' , { action : 'nope' }, tenants )). status , 400 , '모르는 동작' );
r = await ( await setup ( env , 'usage-key' , 'POST' , { action : 'revoke' }, tenants )). json (); eq ( r . enabled , false , '삭제' ); eq (( await usage ( tenants , '' , { key : key1 })). status , 404 , '다시 꺼짐' ); eq ( env . USAGE_API_KEY , '' , 'env 비움' );
tenants . close ();
});
await t ( '인증: Bearer 또는 X-Usage-Key, 틀린 키는 401 이고 IP 마다 10회 뒤 429, 허용 IP 밖은 403, GET 만, 성공 호출은 감사 기록' , async () => {
const { env } = await base (); const tenants = open ( env ); setupToken ( env );
const { key } = await ( await setup ( env , 'usage-key' , 'POST' , { action : 'generate' }, tenants )). json ();
eq (( await usageApi ( tenants , new Request ( 'https://x/api/usage' , { headers : { 'X-Usage-Key' : key } }), { path : [], ip : '1.1.1.1' , query : new URLSearchParams () })). status , 200 , 'X-Usage-Key' );
eq (( await usage ( tenants , '' , {})). status , 401 , '키 없음' ); eq (( await usage ( tenants , '' , { key : key + 'x' })). status , 401 , '틀린 키' );
for ( let i = 0 ; i < USAGE_FAIL_LIMIT ; i ++ ) await usage ( tenants , '' , { key : 'wrong' , ip : '5.5.5.5' });
eq (( await usage ( tenants , '' , { key , ip : '5.5.5.5' })). status , 429 , '실패 10회 뒤 맞는 키도 막힘(15분)' ); eq (( await usage ( tenants , '' , { key , ip : '5.5.5.6' })). status , 200 , '다른 IP 는 정상' );
// 허용 IP
env . USAGE_API_ALLOW = '203.0.113.0/24, 2001:db8::/32' ;
eq (( await usage ( tenants , '' , { key , ip : '203.0.113.77' })). status , 200 , '허용 범위' ); eq (( await ( await usage ( tenants , '' , { key , ip : '198.51.100.1' })). json ()). error , 'forbidden_ip' , '범위 밖' ); eq (( await usage ( tenants , '' , { key , ip : '2001:db8:1::9' })). status , 200 , 'IPv6 허용' );
const a = authorizeUsage ( env , new Request ( 'https://x/' , { headers : { Authorization : 'Bearer ' + key } }), '198.51.100.1' ); eq ( a . status , 403 , 'authorizeUsage 직접' );
env . USAGE_API_ALLOW = '' ;
eq (( await usageApi ( tenants , new Request ( 'https://x/api/usage' , { method : 'POST' , headers : { Authorization : 'Bearer ' + key } }), { path : [], ip : '9.9.9.9' , query : new URLSearchParams () })). status , 405 , 'GET 만' );
eq (( await usage ( tenants , '/%E0%A4%A' , { key })). status , 404 , '잘못된 퍼센트 인코딩은 404(500 아님)' );
// 허용 IP 저장 검증(설정 API): 잘못된 항목이 있으면 422 로 거부해 모든 호출이 403 이 되는 사고를 막음
const bad = await setup ( env , 'config' , 'PUT' , { values : { USAGE_API_ALLOW : '203.0.113.10, webmaker.example.com' } }, tenants ); eq ( bad . status , 422 , '잘못된 허용 목록 거부' ); eq (( await bad . json ()). detail , 'webmaker.example.com' , '어느 항목인지' );
eq (( await setup ( env , 'config' , 'PUT' , { values : { USAGE_API_ALLOW : '203.0.113.0/24' } }, tenants )). status , 200 , '유효한 목록 저장' ); eq ( env . USAGE_API_ALLOW , '203.0.113.0/24' , '적용' ); env . USAGE_API_ALLOW = '' ;
const logs = ( await env . DB . prepare ( "SELECT action,actor,target FROM audit_log WHERE action='usage_read'" ). all ()). results ; ok ( logs . length >= 3 && logs . every (( l ) => l . actor === 'usage-api' ), '감사 기록: ' + logs . length );
tenants . close ();
});
await t ( '보고서: 계정 수(로컬+웹메이커)·저장 용량·메일 수·오늘/이번 주/이달 통수와 바이트·일별 추이·계정별 기간 합계·지운 계정의 메일함·제한 값, 쿼리 days/accounts/series' , async () => {
clearSnapshotCache ();
const { env } = await base ({ LIMIT_ACCOUNTS : '10' , LIMIT_TOTAL_BYTES : '1000000' , LIMIT_MONTHLY_MESSAGES : '100' }); const tenants = open ( env ); setupToken ( env );
const { key } = await ( await setup ( env , 'usage-key' , 'POST' , { action : 'generate' }, tenants )). json ();
// 둘째 도메인: webmaker 모드(관리자 admin + 회원 guest = 외부 계정 2) + 로컬 계정 1
const wm = tmp ( 'arimail-wm-' ); mkdirSync ( join ( wm , 'sites' )); mkdirSync ( join ( wm , 'modules' ));
const h = ( pw , salt ) => scryptSync ( pw , salt , 64 ). toString ( 'hex' ); const salt = randomBytes ( 16 ). toString ( 'hex' );
writeFileSync ( join ( wm , 'users.json' ), JSON . stringify ({ admin : { id : 'admin' , name : '관리자' , salt , passHash : h ( 'pw' , salt ), role : 'admin' }, guest : { id : 'guest' , name : '손님' , salt , passHash : h ( 'pw' , salt ), role : 'user' } }));
await tenants . add ({ domain : 'second.test' , auth : { mode : 'webmaker' , webmakerDir : wm } });
const sec = tenants . byDomain ( 'second.test' ). env ; await createUser ( sec , { login : 'extra' , password : 'extra-pw-12' }, 'test' );
// 기본 도메인 메일: 오늘 발신 2(3000B)·수신 1(500B), 어제 수신 2, 40일 전 발신 1(기간 밖), 초안 1, 실패 1, 지운 계정의 메일함에 수신 1
await createUser ( env , { login : 'kim' , password : 'kim-pw-1234' }, 'test' );
const today = daysAgo ( 0 ), yesterday = daysAgo ( 1 );
await putMail ( env , { direction : 'outbound' , at : today , size : 1000 , mailbox : 'kim@arihakdang.com' }); await putMail ( env , { direction : 'outbound' , at : today , size : 2000 , mailbox : 'kim@arihakdang.com' });
await putMail ( env , { direction : 'inbound' , at : today , size : 500 , mailbox : 'kim@arihakdang.com' }); await putMail ( env , { direction : 'inbound' , at : yesterday , size : 100 , mailbox : 'admin@arihakdang.com' }); await putMail ( env , { direction : 'inbound' , at : yesterday , size : 100 , mailbox : 'admin@arihakdang.com' });
await putMail ( env , { direction : 'outbound' , at : daysAgo ( 40 ), size : 700 , mailbox : 'kim@arihakdang.com' });
await putMail ( env , { direction : 'outbound' , at : today , size : 10 , mailbox : 'kim@arihakdang.com' , state : 'draft' }); await putMail ( env , { direction : 'outbound' , at : today , size : 10 , mailbox : 'kim@arihakdang.com' , state : 'failed' });
await putMail ( env , { direction : 'inbound' , at : today , size : 300 , mailbox : 'gone@arihakdang.com' });
// 전체 요약
const all = await ( await usage ( tenants , '' , { key })). json ();
eq ( all . totals . domains , 2 , '도메인 수' ); eq ( all . primary , 'arihakdang.com' , '기본' ); eq ( all . domains [ 0 ]. domain , 'arihakdang.com' , '기본 먼저' ); ok ( ! all . domains [ 0 ]. accountList && ! all . domains [ 0 ]. daily , '요약에는 계정 목록·일별 없음' );
const p = all . domains [ 0 ];
eq ( p . accounts . local , 1 , '로컬 계정' ); eq ( p . accounts . limit , 10 , '계정 제한' ); eq ( p . messages . total , 9 , '메일 수' ); eq ( p . messages . inbound , 4 , '수신' ); eq ( p . messages . outbound , 3 , '발신 완료' ); eq ( p . messages . drafts , 1 , '초안' ); eq ( p . messages . failed , 1 , '실패' );
eq ( p . storage . bytes , 1000 + 2000 + 500 + 200 + 700 + 20 + 300 , '저장 용량(모든 원본 크기)' ); eq ( p . storage . limit , 1000000 , '제한' ); eq ( p . storage . percent , 0 , '비율' ); eq ( p . storage . largeFiles . count , 0 , '대용량 파일' );
eq ( p . traffic . day . sent , 2 , '오늘 발신' ); eq ( p . traffic . day . sentBytes , 3000 , '오늘 발신 바이트' ); eq ( p . traffic . day . received , 2 , '오늘 수신(gone 포함)' ); eq ( p . traffic . month . total >= 4 , true , '이달' ); eq ( p . traffic . monthlyLimit , 100 , '월간 제한' ); eq ( p . limits . system . accounts , 10 , '시스템 제한' );
eq ( p . authMode , 'www' , '로그인 방식' ); eq ( p . inbound , 'resend' , '수신 방식' ); eq ( p . enabled , true , '사용 중' ); eq ( p . hosts [ 0 ], 'mail.arihakdang.com' , '호스트' );
const s = all . domains [ 1 ]; eq ( s . accounts . local , 1 , '둘째 로컬' ); eq ( s . accounts . external , 2 , '둘째 웹메이커 계정' ); eq ( s . accounts . total , 3 , '합계' ); eq ( s . accounts . externalSource . kind , 'webmaker' , '외부 출처' ); eq ( s . authMode , 'webmaker' , '둘째 방식' );
// 도메인 상세: 일별·계정 목록 기본 포함
const d = await ( await usage ( tenants , '/arihakdang.com' , { key , query : 'days=7' })). json ();
eq ( d . domain , 'arihakdang.com' , '상세' ); eq ( d . period . days , 7 , '기간' ); eq ( d . daily . length , 7 , '일별 7칸' ); const last = d . daily [ 6 ]; eq ( last . sent , 2 , '오늘 칸 발신' ); eq ( last . received , 2 , '오늘 칸 수신' ); eq ( last . sentBytes , 3000 , '오늘 바이트' ); eq ( d . daily [ 5 ]. received , 2 , '어제 칸' ); ok ( /^\d{4}-\d{2}-\d{2}$/ . test ( last . date ), '날짜 형식' );
const kim = d . accountList . find (( a ) => a . login === 'kim' ); ok ( kim , '계정 행' ); eq ( kim . source , 'local' , '출처' ); eq ( kim . messages , 6 , 'kim 메일 수' ); eq ( kim . bytes , 1000 + 2000 + 500 + 700 + 20 , 'kim 용량' ); eq ( kim . sent , 2 , '기간 발신(40일 전 제외)' ); eq ( kim . received , 1 , '기간 수신' ); eq ( kim . sentBytes , 3000 , '기간 발신 바이트' ); eq ( kim . limit , 0 , '크기 제한 없음' );
const adminRow = d . accountList . find (( a ) => a . mailbox === 'admin@arihakdang.com' ); ok ( adminRow && adminRow . source === 'www' , 'www 관리자 메일함 행' ); eq ( adminRow . received , 2 , '관리자 수신' );
const gone = d . accountList . find (( a ) => a . mailbox === 'gone@arihakdang.com' ); ok ( gone && gone . source === 'orphan' && gone . status === 'deleted' && gone . messages === 1 , '지운 계정의 메일함' );
const d2 = await ( await usage ( tenants , '/second.test' , { key , query : 'days=3&series=0' })). json (); ok ( ! d2 . daily , 'series=0' ); eq ( d2 . accountList . length , 3 , '둘째 계정 목록(로컬 1 + 웹메이커 2)' ); eq ( d2 . accountList . filter (( a ) => a . source === 'webmaker' ). length , 2 , '웹메이커 행' );
eq (( await usage ( tenants , '/nope.test' , { key })). status , 404 , '모르는 도메인' );
const d3 = await ( await usage ( tenants , '' , { key , query : 'accounts=1&days=9999' })). json (); ok ( d3 . domains [ 0 ]. accountList , 'accounts=1 로 요약에도 계정 목록' ); eq ( d3 . domains [ 0 ]. period . days , 30 , '범위 밖 days 는 30' );
// 비밀값이 섞이지 않는지
const text = JSON . stringify ( all ) + JSON . stringify ( d ); ok ( ! text . includes ( key ) && ! text . includes ( 'RESEND' ) && ! text . includes ( 'password' ), '키·비밀값 없음' );
const direct = await domainUsage ( env , { days : 2 , accounts : false , series : true }); eq ( direct . daily . length , 2 , 'domainUsage 직접' );
// 서머타임 시간대: 기간 시작이 늘 그 날의 0시(통계 시간대)이고 첫 칸의 날짜와 같다(24시간씩 빼기만 하면 한 시간 밀림)
env . STATS_TIMEZONE = 'America/New_York' ;
for ( const n of [ 7 , 30 , 120 , 366 ]) { const r = await domainUsage ( env , { days : n , accounts : false , series : true }); const startMs = Date . parse ( r . period . start ); eq ( periodBounds ( 'day' , 'America/New_York' , new Date ( startMs + 43200000 )). start , r . period . start , `DST 시간대 ${ n } 일: 시작은 0시` ); eq ( r . daily . length , n , ` ${ n } 칸` ); eq ( r . daily [ 0 ]. date , new Intl . DateTimeFormat ( 'en-CA' , { timeZone : 'America/New_York' }). format ( new Date ( startMs + 43200000 )), '첫 칸 날짜' ); }
env . STATS_TIMEZONE = 'Asia/Seoul' ;
// 도메인 하나가 깨져도 전체 요약은 나머지를 돌려준다
const brokenPrepare = sec . DB . prepare ; sec . DB . prepare = () => { throw new Error ( 'SQLITE_IOERR' ); };
const partial = await ( await usage ( tenants , '' , { key })). json (); eq ( partial . ok , true , '요약 ok' ); eq ( partial . domains [ 1 ]. error , 'server_error' , '깨진 도메인 표시' ); eq ( partial . domains [ 1 ]. domain , 'second.test' , '도메인 이름' ); eq ( partial . totals . domains , 2 , '합계는 정상 도메인만' ); eq ( partial . totals . messages , 9 , '합계 메일 수' );
eq (( await usage ( tenants , '/second.test' , { key })). status , 500 , '상세는 500' ); sec . DB . prepare = brokenPrepare ;
tenants . close ();
});
await t ( 'Resend 연결 오류(ECONNRESET 등)는 2초 뒤 한 번 더 시도하고, 두 번째도 실패하면 mail_provider_unavailable, 응답 오류(4xx)는 재시도하지 않는다' , async () => {
const env = await makeEnv ({ RESEND_RETRY_MS : 10 }); let calls = 0 ;
env . RESEND_FETCH = async () => { calls ++ ; if ( calls === 1 ) { const e = new TypeError ( 'fetch failed' ); e . cause = { code : 'ECONNRESET' }; throw e ; } return Response . json ({ data : [] }); };
eq ( JSON . stringify ( await resend ( env , '/emails' )), '{"data":[]}' , '재시도 성공' ); eq ( calls , 2 , '2회 호출' );
calls = 0 ; env . RESEND_FETCH = async () => { calls ++ ; const e = new TypeError ( 'fetch failed' ); e . cause = { code : 'UND_ERR_CONNECT_TIMEOUT' }; throw e ; };
let err = null ; try { await resend ( env , '/emails' ); } catch ( e ) { err = e ; } eq ( err ? . message , 'mail_provider_unavailable' , '두 번 실패' ); eq ( err ? . providerMessage , 'UND_ERR_CONNECT_TIMEOUT' , '사유' ); eq ( calls , 2 , '정확히 2회' );
calls = 0 ; env . RESEND_FETCH = async () => { calls ++ ; return Response . json ({ message : 'nope' }, { status : 403 }); };
err = null ; try { await resend ( env , '/emails' ); } catch ( e ) { err = e ; } eq ( err ? . message , 'mail_forbidden' , '응답 오류' ); eq ( calls , 1 , '응답 오류는 재시도 없음' );
// 응답 대기 시간 초과(TimeoutError)와 멱등 키 없는 POST(웹훅 만들기 등)는 재시도하지 않는다 — 요청이 이미 처리됐을 수 있음
calls = 0 ; env . RESEND_FETCH = async () => { calls ++ ; const e = new DOMException ( 'The operation was aborted due to timeout' , 'TimeoutError' ); throw e ; };
err = null ; try { await resend ( env , '/emails' ); } catch ( e ) { err = e ; } eq ( err ? . message , 'mail_provider_unavailable' , '시간 초과' ); eq ( calls , 1 , 'TimeoutError 는 재시도 없음' );
calls = 0 ; env . RESEND_FETCH = async () => { calls ++ ; if ( calls === 1 ) { const e = new TypeError ( 'fetch failed' ); e . cause = { code : 'ECONNRESET' }; throw e ; } return Response . json ({ id : 'wh_1' }); };
err = null ; try { await resend ( env , '/webhooks' , 'POST' , { endpoint : 'x' }); } catch ( e ) { err = e ; } eq ( err ? . message , 'mail_provider_unavailable' , '멱등 키 없는 POST 는 재시도 없음' ); eq ( calls , 1 , '1회' );
calls = 0 ; eq (( await resend ( env , '/emails' , 'POST' , { to : 'a' }, 'idem-1' )). id , 'wh_1' , '멱등 키 있는 POST 는 재시도' ); eq ( calls , 2 , '2회' );
});
done ();