diff --git a/.gitignore b/.gitignore index 2fa399f8..77389540 100755 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules/ .DS_Store # Logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/app/api/astrology-mathbrain/route.ts b/app/api/astrology-mathbrain/route.ts index 6e362881..92580e1c 100644 --- a/app/api/astrology-mathbrain/route.ts +++ b/app/api/astrology-mathbrain/route.ts @@ -5,8 +5,7 @@ import { randomUUID } from 'crypto'; export const maxDuration = 26; // seconds export const dynamic = 'force-dynamic'; // Disable caching for this route -// Reuse the legacy math brain implementation directly -const mathBrainFunction = require('../../../lib/server/astrology-mathbrain.js'); +import { mathBrainService } from '@/src/math_brain/service'; // NEW: Import the v2 Math Brain orchestrator const { runMathBrain } = require('../../../src/math_brain/main.js'); @@ -88,57 +87,12 @@ function buildFailureResponse(status: number, payload: any, rawBody: string | nu } export async function GET(request: NextRequest) { - // Convert Next.js request to Netlify event format - const url = new URL(request.url); - - // Convert headers - const headers: Record = {}; - request.headers.forEach((value, key) => { - headers[key] = value; + return NextResponse.json({ + success: true, + message: 'Astrology MathBrain v2 service is active.', + service: 'astrology-mathbrain', + version: 'v2' }); - - const event = { - httpMethod: 'GET', - queryStringParameters: Object.fromEntries(url.searchParams), - headers, - body: null, - path: url.pathname, - pathParameters: null, - requestContext: {}, - resource: '', - stageVariables: null, - isBase64Encoded: false - }; - - const context = { - callbackWaitsForEmptyEventLoop: false, - functionName: 'astrology-mathbrain', - functionVersion: '$LATEST', - invokedFunctionArn: '', - memoryLimitInMB: '1024', - awsRequestId: randomUUID(), - logGroupName: '', - logStreamName: '', - getRemainingTimeInMillis: () => 30000 - }; - - try { - const result = await mathBrainFunction.handler(event, context); - - return new NextResponse(result.body, { - status: result.statusCode, - headers: new Headers(result.headers || {}) - }); - } catch (error: any) { - logger.error('Astrology MathBrain API error', { - error: error instanceof Error ? error.message : String(error) - }); - return NextResponse.json({ - success: false, - error: 'Internal server error', - code: 'ASTROLOGY_API_ERROR' - }, { status: 500 }); - } } export async function POST(request: NextRequest) { @@ -149,93 +103,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }); } - const body = JSON.stringify(rawPayload); - - const windowConfig = rawPayload?.window || null; - const windowStep = typeof windowConfig?.step === 'string' ? windowConfig.step.toLowerCase() : null; - - if (windowStep === 'daily' && windowConfig?.start && windowConfig?.end) { - const startDate = parseIsoDate(windowConfig.start); - const endDate = parseIsoDate(windowConfig.end); - if (!startDate || !endDate) { - logger.warn('Invalid daily window dates received', { start: windowConfig.start, end: windowConfig.end }); - return NextResponse.json({ - success: false, - error: 'Invalid transit window dates. Please use ISO format (YYYY-MM-DD).', - code: 'INVALID_TRANSIT_WINDOW' - }, { status: 400 }); - } - - if (endDate.getTime() < startDate.getTime()) { - logger.warn('Daily window end precedes start', { start: windowConfig.start, end: windowConfig.end }); - return NextResponse.json({ - success: false, - error: 'Transit window end date must be on or after the start date.', - code: 'INVALID_TRANSIT_WINDOW_ORDER' - }, { status: 400 }); - } - - const totalDays = countInclusiveDays(startDate, endDate); - if (totalDays > MAX_DAILY_TRANSIT_WINDOW_DAYS) { - logger.warn('Daily window exceeds maximum allowed span', { totalDays, start: windowConfig.start, end: windowConfig.end }); - return NextResponse.json({ - success: false, - error: `Daily symbolic weather windows are limited to ${MAX_DAILY_TRANSIT_WINDOW_DAYS} days. Consider weekly sampling or shorten the range.`, - code: 'TRANSIT_WINDOW_TOO_LARGE', - limit: MAX_DAILY_TRANSIT_WINDOW_DAYS - }, { status: 400 }); - } - } - - // Prepare event for legacy handler - const url = new URL(request.url); - const headers: Record = {}; - request.headers.forEach((value, key) => { headers[key] = value; }); - - const event = { - httpMethod: 'POST', - headers, - body, - queryStringParameters: Object.fromEntries(url.searchParams), - path: url.pathname, pathParameters: null, requestContext: {}, resource: '', stageVariables: null, isBase64Encoded: false - }; - - const context = { - callbackWaitsForEmptyEventLoop: false, functionName: 'astrology-mathbrain', functionVersion: '$LATEST', invokedFunctionArn: '', - memoryLimitInMB: '1024', awsRequestId: randomUUID(), logGroupName: '', logStreamName: '', getRemainingTimeInMillis: () => 30000 - }; - // Execute the unified pipeline logger.info('Routing to unified Math Brain v2 pipeline'); try { - // Get raw chart data by calling the legacy handler - const legacyResult = await mathBrainFunction.handler(event, context); - const legacyStatus = legacyResult?.statusCode ?? 500; - let legacyBody: any = null; - let rawBody: string | null = null; - - if (legacyResult?.body) { - rawBody = legacyResult.body; - try { - legacyBody = JSON.parse(legacyResult.body); - } catch { - legacyBody = { error: legacyResult.body }; - } - } - - if (!legacyResult || legacyStatus >= 400) { - logger.error('Failed to fetch raw chart data from legacy handler', { - statusCode: legacyStatus, - errorCode: legacyBody?.code, - errorMessage: legacyBody?.error, - errorDetails: legacyBody - }); - - return buildFailureResponse(legacyStatus, legacyBody, rawBody); - } - - const chartData = legacyBody; + // Get raw chart data by calling the new Math Brain Service + const chartData = await mathBrainService.fetch(rawPayload); // Prepare the config for the v2 formatter/aggregator const relationshipContextRaw = diff --git a/src/math_brain/service.ts b/src/math_brain/service.ts new file mode 100644 index 00000000..0c2fbb0d --- /dev/null +++ b/src/math_brain/service.ts @@ -0,0 +1,148 @@ +// src/math_brain/service.ts + +/** + * @module mathBrainService + * + * This service is responsible for orchestrating the fetching and processing of + * astrological chart data. It acts as a replacement for the monolithic + * `lib/server/astrology-mathbrain.js` file, providing a modern, testable, + * and maintainable interface for the Math Brain v2 system. + */ + +import { + normalizeSubjectData, + validateSubject, + buildHeaders, + fetchNatalChartComplete, + getTransits, + calculateSeismograph, + computeComposite, +} from './utils'; + + +interface ChartDataPayload { + [key: string]: any; +} + +interface ChartDataResult { + success: boolean; + provenance: any; + person_a: any; + person_b?: any; + synastry?: any; + composite?: any; + woven_map?: any; + relationship_context?: any; + transitsByDate?: any; + [key: string]: any; // Allow other properties to match monolith's output +} + +class MathBrainService { + /** + * Fetches and processes all necessary astrological data for a given request payload. + * This is the primary entry point for the service. + * + * @param {ChartDataPayload} rawPayload - The raw request body from the API route. + * @returns {Promise} The processed chart data, ready for the v2 orchestrator. + */ + public async fetch(rawPayload: ChartDataPayload): Promise { + const personA = normalizeSubjectData(rawPayload.personA || {}); + const validation = validateSubject(personA); + if (!validation.isValid) { + throw new Error(`Primary subject validation failed: ${validation.message}`); + } + + const headers = buildHeaders(); + const pass = {}; + [ + 'active_points', 'active_aspects', 'houses_system_identifier', + 'sidereal_mode', 'perspective_type', 'wheel_only', 'wheel_format', + 'theme', 'language' + ].forEach((key) => { + if (rawPayload[key] !== undefined) pass[key] = rawPayload[key]; + }); + + const personANatal = await fetchNatalChartComplete( + personA, + headers, + pass, + 'person_a', + rawPayload.mode || 'standard' + ); + + const result: ChartDataResult = { + success: true, + provenance: {}, + person_a: { + details: personANatal.details, + chart: personANatal.chart, + aspects: personANatal.aspects, + assets: personANatal.assets || [], + }, + }; + + const win = rawPayload.window || null; + const start = win?.start; + const end = win?.end; + const step = win?.step || 'daily'; + const haveRange = Boolean(start && end); + + if (haveRange) { + const { transitsByDate, retroFlagsByDate, provenanceByDate } = await getTransits( + personA, + { startDate: start, endDate: end, step: step }, + headers, + pass + ); + const seismographData = calculateSeismograph(transitsByDate, retroFlagsByDate, { + orbsProfile: rawPayload.orbs_profile || 'wm-tight-2025-11-v5' + }); + result.person_a.chart.transitsByDate = seismographData.daily; + result.person_a.chart.provenanceByDate = provenanceByDate; + result.person_a.derived = { + seismograph_summary: seismographData.summary, + }; + } + + const hasPersonB = rawPayload.personB && Object.keys(rawPayload.personB).length > 0; + const mode = rawPayload.mode || ''; + const isSynastry = mode.includes('SYNASTRY'); + const isComposite = mode.includes('COMPOSITE'); + const isRelational = isSynastry || isComposite || hasPersonB; + + if (isRelational) { + if (!hasPersonB) { + throw new Error('Relational report requested but personB is missing.'); + } + const personB = normalizeSubjectData(rawPayload.personB); + const validationB = validateSubject(personB); + if (!validationB.isValid) { + throw new Error(`Secondary subject validation failed: ${validationB.message}`); + } + const personBNatal = await fetchNatalChartComplete( + personB, + headers, + pass, + 'person_b', + mode + ); + result.person_b = { + details: personBNatal.details, + chart: personBNatal.chart, + aspects: personBNatal.aspects, + assets: personBNatal.assets || [], + }; + if (isComposite) { + const composite = await computeComposite(personA, personB, pass, headers); + result.composite = { + aspects: composite.aspects, + data: composite.raw, + }; + } + } + + return result; + } +} + +export const mathBrainService = new MathBrainService(); diff --git a/src/math_brain/utils.ts b/src/math_brain/utils.ts new file mode 100644 index 00000000..6042177d --- /dev/null +++ b/src/math_brain/utils.ts @@ -0,0 +1,294 @@ +// src/math_brain/utils.ts +/* eslint-disable no-console */ + +// This file is a collection of ported helper functions from the legacy +// `lib/server/astrology-mathbrain.js` monolith. + +import { aggregate, _internals as seismoInternals } from '../../src/seismograph.js'; +import { DateTime } from 'luxon'; + +const API_BASE_URL = 'https://astrologer.p.rapidapi.com'; + +const API_ENDPOINTS = { + BIRTH_CHART: `${API_BASE_URL}/api/v4/birth-chart`, + NATAL_ASPECTS_DATA: `${API_BASE_URL}/api/v4/natal-aspects-data`, + SYNASTRY_CHART: `${API_BASE_URL}/api/v4/synastry-chart`, + TRANSIT_CHART: `${API_BASE_URL}/api/v4/transit-chart`, + TRANSIT_ASPECTS: `${API_BASE_URL}/api/v4/transit-aspects-data`, + SYNASTRY_ASPECTS: `${API_BASE_URL}/api/v4/synastry-aspects-data`, + BIRTH_DATA: `${API_BASE_URL}/api/v4/birth-data`, + NOW: `${API_BASE_URL}/api/v4/now`, + COMPOSITE_ASPECTS: `${API_BASE_URL}/api/v4/composite-aspects-data`, + COMPOSITE_CHART: `${API_BASE_URL}/api/v4/composite-chart`, +}; + +const logger = { + log: (...args: any[]) => console.log(`[LOG]`, ...args), + info: (...args: any[]) => console.info(`[INFO]`, ...args), + warn: (...args: any[]) => console.warn(`[WARN]`, ...args), + error: (...args: any[]) => console.error(`[ERROR]`, ...args), + debug: (...args: any[]) => process.env.LOG_LEVEL === 'debug' && console.debug(`[DEBUG]`, ...args), +}; +let loggedMissingRapidApiKey = false; + +export function buildHeaders() { + const rawKey = process.env.RAPIDAPI_KEY; + const key = rawKey && String(rawKey).trim(); + if (!key) { + if (!loggedMissingRapidApiKey) { + logger.error('RAPIDAPI_KEY environment variable is not configured.'); + loggedMissingRapidApiKey = true; + } + throw new Error('RAPIDAPI_KEY environment variable is not configured.'); + } + return { + "content-type": "application/json", + "x-rapidapi-key": key, + "x-rapidapi-host": "astrologer.p.rapidapi.com", + }; +} + +export async function apiCallWithRetry(url: string, options: any, operation: string, maxRetries = 2) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.debug(`API call attempt ${attempt}/${maxRetries} for ${operation}`); + const response = await fetch(url, options); + + if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + const status = response.status; + let rawText = ''; + try { rawText = await response.text(); } catch { rawText = 'Unable to read response body'; } + throw new Error(`Client error ${status} for ${operation}`); + } + logger.warn(`API call failed with status ${response.status}. Retrying...`); + throw new Error(`Server error: ${response.status}`); + } + return response.json(); + } catch (error: any) { + if (attempt === maxRetries) { + logger.error(`Failed after ${attempt} attempts: ${error.message}`, { url, operation }); + throw new Error(`Service temporarily unavailable. Please try again later.`); + } + const delay = Math.pow(2, attempt) * 100 + Math.random() * 100; + await new Promise(res => setTimeout(res, delay)); + } + } +} + +export function normalizeTimezone(tz: string) { + if (!tz || typeof tz !== 'string') return tz; + const t = tz.trim().toUpperCase(); + const timezoneMap: Record = { + 'EASTERN': 'America/New_York', 'EST': 'America/New_York', 'EDT': 'America/New_York', + 'CENTRAL': 'America/Chicago', 'CST': 'America/Chicago', 'CDT': 'America/Chicago', + 'MOUNTAIN': 'America/Denver', 'MST': 'America/Denver', 'MDT': 'America/Denver', + 'PACIFIC': 'America/Los_Angeles', 'PST': 'America/Los_Angeles', 'PDT': 'America/Los_Angeles', + }; + if (timezoneMap[t]) { + return timezoneMap[t]; + } + try { + return new Intl.DateTimeFormat('en-US', { timeZone: tz }).resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +export function normalizeSubjectData(data: any): any { + if (!data || typeof data !== 'object') return {}; + const normalized: any = { + name: data.name || 'Subject', + year: data.year, month: data.month, day: data.day, + hour: data.hour, minute: data.minute, + city: data.city, nation: data.nation, + latitude: data.latitude ?? data.lat, + longitude: data.longitude ?? data.lon ?? data.lng, + timezone: normalizeTimezone(data.timezone || data.tz_str), + zodiac_type: data.zodiac_type || data.zodiac || 'Tropic', + }; + return normalized; +} + +export function validateSubject(subject: any) { + const baseReq = ['year','month','day','hour','minute','name','zodiac_type']; + const baseMissing = baseReq.filter(f => subject[f] === undefined || subject[f] === null || subject[f] === ''); + const hasCoords = (typeof subject.latitude === 'number') && (typeof subject.longitude === 'number') && !!subject.timezone; + const hasCity = !!(subject.city && subject.nation); + const okMode = hasCoords || hasCity; + const modeMsg = okMode ? '' : 'coords(lat,lon,timezone) OR city,nation required'; + const missingMsg = baseMissing.length ? `Missing: ${baseMissing.join(', ')}` : ''; + return { isValid: baseMissing.length === 0 && okMode, message: [missingMsg, modeMsg].filter(Boolean).join('; ') || 'ok' }; +} + +export function subjectToAPI(s: any = {}, pass: any = {}) { + if (!s) return {}; + const hasCoords = (typeof s.latitude === 'number' || typeof s.lat === 'number') && + (typeof s.longitude === 'number' || typeof s.lon === 'number' || typeof s.lng === 'number') && + (s.timezone || s.tz_str); + const hasCity = !!(s.city && s.nation); + const tzNorm = normalizeTimezone(s.timezone || s.tz_str); + const apiSubject: any = { + name: s.name, + year: s.year, month: s.month, day: s.day, + hour: s.hour, minute: s.minute, + zodiac_type: s.zodiac_type || 'Tropic' + }; + if (hasCoords) { + apiSubject.latitude = s.latitude ?? s.lat; + apiSubject.longitude = s.longitude ?? s.lon ?? s.lng; + apiSubject.timezone = tzNorm; + } + if (hasCity) { + apiSubject.city = s.state ? `${s.city}, ${s.state}` : s.city; + apiSubject.nation = s.nation; + } + return apiSubject; +} + +export async function callNatal(endpoint: string, subject: any, headers: any, pass: any = {}, description = 'Natal call'){ + const payload = { subject: subjectToAPI(subject, pass) }; + return await apiCallWithRetry(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) }, description); +} + +export async function fetchNatalChartComplete(subject: any, headers: any, pass: any, subjectLabel: string, contextLabel: string) { + const natalResponse = await callNatal( + API_ENDPOINTS.BIRTH_CHART, + subject, + headers, + pass, + `Birth chart (${subjectLabel}) - ${contextLabel}` + ); + + const chartData = natalResponse.data || {}; + const natalData = { + details: subject, + chart: chartData, + aspects: Array.isArray(natalResponse.aspects) ? natalResponse.aspects : (chartData.aspects || []), + assets: [] + }; + return natalData; +} + +function normalizeStep(step: string) { + const s = String(step || '').toLowerCase(); + if (['daily','weekly','monthly'].includes(s)) return s; + return 'daily'; +} + + +export async function getTransits(subject: any, transitParams: any, headers: any, pass: any = {}) { + if (!transitParams || !transitParams.startDate || !transitParams.endDate) return {}; + + const { buildWindowSamples } = require('../../lib/time-sampling'); + const transitsByDate: Record = {}; + const retroFlagsByDate: Record = {}; + const provenanceByDate: Record = {}; + const chartAssets: any[] = []; + + const ianaTz = subject?.timezone || 'UTC'; + const step = normalizeStep(transitParams.step || 'daily'); + const samplingWindow = buildWindowSamples( + { start: transitParams.startDate, end: transitParams.endDate, step }, + ianaTz, + transitParams?.timeSpec || null + ); + const samples = Array.isArray(samplingWindow?.samples) ? samplingWindow.samples : []; + const samplingZone = samplingWindow?.zone || ianaTz || 'UTC'; + + const CHUNK_SIZE = 5; + + for (let chunkStart = 0; chunkStart < samples.length; chunkStart += CHUNK_SIZE) { + const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, samples.length); + const chunkSamples = samples.slice(chunkStart, chunkEnd); + const chunkPromises: Promise[] = []; + + for (const sampleIso of chunkSamples) { + const utcDate = DateTime.fromISO(sampleIso, { zone: 'utc' }); + let localDate = utcDate.setZone(samplingZone); + if (!localDate.isValid) { + localDate = utcDate; + } + const dateString = localDate.toISODate(); + const transit_subject = { + year: localDate.year, + month: localDate.month, + day: localDate.day, + hour: localDate.hour, + minute: localDate.minute, + zodiac_type: 'Tropic', + timezone: samplingZone + }; + + const payload = { + first_subject: subjectToAPI(subject, pass), + transit_subject: subjectToAPI(transit_subject, pass), + ...pass + }; + + chunkPromises.push( + (async () => { + const resp = await apiCallWithRetry( + API_ENDPOINTS.TRANSIT_ASPECTS, + { method: 'POST', headers, body: JSON.stringify(payload) }, + `Transits for ${subject.name} on ${dateString}` + ); + if (resp && resp.aspects && resp.aspects.length > 0) { + transitsByDate[dateString] = resp.aspects; + } + })().catch(e => logger.error(`Failed to get transits for ${dateString}`, e)) + ); + } + await Promise.all(chunkPromises); + } + return { transitsByDate, retroFlagsByDate, provenanceByDate, chartAssets }; +} + + +export function calculateSeismograph(transitsByDate: any, retroFlagsByDate = {}, options: any = {}) { + const daily: Record = {}; + const summary: Record = {}; + const graphRows: any[] = []; + + for (const date in transitsByDate) { + const aspects = transitsByDate[date]; + const agg = aggregate(aspects.map((a: any) => ({ + transit: { body: a.p1_name }, + natal: { body: a.p2_name }, + type: a.aspect, + orbDeg: a.orbit + })), null, {}); + + daily[date] = { seismograph: agg }; + graphRows.push({ date, magnitude: agg.magnitude, bias_signed: agg.directional_bias }); + } + + const numDays = Object.keys(daily).length; + if (numDays > 0) { + summary.magnitude = graphRows.reduce((sum, row) => sum + row.magnitude, 0) / numDays; + summary.directional_bias = graphRows.reduce((sum, row) => sum + row.bias_signed, 0) / numDays; + } + + return { daily, summary, graph_rows: graphRows }; +} + +export async function computeComposite(A: any, B: any, pass: any = {}, H: any) { + try { + const payload = { + first_subject: subjectToAPI(A, pass), + second_subject: subjectToAPI(B, pass), + ...pass, + }; + const r = await apiCallWithRetry( + API_ENDPOINTS.COMPOSITE_ASPECTS, + { method: 'POST', headers: H, body: JSON.stringify(payload) }, + 'Composite aspects' + ); + const data = r.data || {}; + const topAspects = Array.isArray(r.aspects) ? r.aspects : (data.aspects || []); + return { aspects: topAspects, raw: data }; + } catch (error: any) { + logger.error('Composite calculation failed:', error); + throw new Error(`Composite calculation failed: ${error.message}`); + } +} diff --git a/test/astrology-mathbrain.test.js b/test/astrology-mathbrain.test.js index c4ac354c..a6878ebd 100644 --- a/test/astrology-mathbrain.test.js +++ b/test/astrology-mathbrain.test.js @@ -101,12 +101,6 @@ function setupTestEnvironment() { }; } -function loadModule() { - const modulePath = path.join(__dirname, '..', 'lib', 'server', 'astrology-mathbrain.js'); - delete require.cache[require.resolve(modulePath)]; - return require(modulePath); -} - const VALID_PERSON_A = { name: 'Test Person A', year: 1990, month: 5, day: 15, hour: 14, minute: 30, latitude: 40.7128, longitude: -74.0060, city: 'New York', nation: 'US', @@ -122,356 +116,27 @@ const VALID_TRANSIT_PARAMS = { startDate: '2024-01-01', endDate: '2024-01-01' }; async function runTests() { - setupTestEnvironment(); + // setupTestEnvironment(); // This mocks fetch, which we don't want for integration tests const runner = new TestRunner(); - const { handler } = loadModule(); - - runner.test('Should return 405 for non-POST requests', async () => { - const result = await handler({ httpMethod: 'GET' }); - runner.assertEqual(result.statusCode, 405); - }); - - runner.test('Should return 400 if Person A is invalid', async () => { - const result = await handler({ httpMethod: 'POST', body: JSON.stringify({ personA: INVALID_PERSON }) }); - runner.assertEqual(result.statusCode, 400); - runner.assert(JSON.parse(result.body).error.includes('Primary subject validation failed')); - }); - - runner.test('Should handle natal chart mode', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ personA: VALID_PERSON_A, context: { mode: 'BIRTH_CHART' } }) }; - const result = await handler(event); - runner.assertEqual(result.statusCode, 200); - const body = JSON.parse(result.body); - runner.assert(body.person_a.chart.aspects, 'Should have natal aspects'); - }); + const API_URL = 'http://localhost:3006/api/astrology-mathbrain'; runner.test('Should handle transits and Seismograph for a single person', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ personA: VALID_PERSON_A, transitParams: VALID_TRANSIT_PARAMS, context: { mode: 'NATAL_TRANSITS' } }) }; - const result = await handler(event); - runner.assertEqual(result.statusCode, 200); - const body = JSON.parse(result.body); - runner.assert(body.person_a.chart.transitsByDate, 'Should have transitsByDate'); - runner.assert(body.person_a.chart.transitsByDate['2024-01-01'].seismograph, 'Daily transit should have seismograph data'); - runner.assert(body.person_a.derived.seismograph_summary, 'Should have seismograph summary'); - }); - - runner.test('Directional bias remains signed for contraction days', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ personA: VALID_PERSON_A, transitParams: VALID_TRANSIT_PARAMS, context: { mode: 'NATAL_TRANSITS' } }) }; - const result = await handler(event); - runner.assertEqual(result.statusCode, 200); - const body = JSON.parse(result.body); - const daily = body.person_a.chart.transitsByDate['2024-01-01']; - runner.assert(daily?.seismograph, 'Expected seismograph data on the contraction day'); - // v4: Use canonical directional_bias.value - const bias = daily.seismograph.directional_bias?.value; - runner.assert(typeof bias === 'number', 'directional_bias.value should be numeric'); - runner.assert(bias <= 0, `Expected inward or neutral bias, received ${bias}`); - const summaryBias = body.person_a.derived.seismograph_summary?.directional_bias?.value; - runner.assert(typeof summaryBias === 'number', 'Summary should carry directional_bias.value'); - runner.assert(summaryBias <= 0, `Summary bias should mirror contraction, received ${summaryBias}`); - }); - - runner.test('Should handle synastry mode', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ - personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - context: { mode: 'SYNASTRY' }, - // Relationship context required by handler after recent merge - relationship_context: { type: 'FRIEND', role: 'Acquaintance' } - }) }; - const result = await handler(event); - runner.assertEqual(result.statusCode, 200); - const body = JSON.parse(result.body); - runner.assert(Array.isArray(body.synastry_aspects), 'Should have synastry aspects'); - }); - - runner.test('Relationship contact_state toggle respected', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ - personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - context: { mode: 'SYNASTRY' }, - relationship_context: { type: 'PARTNER', intimacy_tier: 'P3', contact_state: 'LATENT' } - }) }; - const result = await handler(event); - runner.assertEqual(result.statusCode, 200); - const body = JSON.parse(result.body); - runner.assert(body.relationship, 'Relationship context should be echoed'); - runner.assertEqual(body.relationship.contact_state, 'LATENT', 'contact_state should reflect toggle'); - }); - - runner.test('Should handle synastry with transits and Seismograph for BOTH people', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ + const payload = { personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - transitParams: VALID_TRANSIT_PARAMS, - context: { mode: 'SYNASTRY_TRANSITS' }, - relationship_context: { type: 'FRIEND', role: 'Acquaintance' } - }) }; - const result = await handler(event); - const body = JSON.parse(result.body); - - runner.assertEqual(result.statusCode, 200); - runner.assert(body.person_a.chart.transitsByDate, 'Person A should have transits'); - runner.assert(body.person_b.chart.transitsByDate, 'Person B should have transits'); - runner.assert(Array.isArray(body.synastry_aspects), 'Should have synastry aspects'); - runner.assert(body.person_a.derived.seismograph_summary, 'Person A should have seismograph summary'); - runner.assert(body.person_b.derived.seismograph_summary, 'Person B should have seismograph summary'); - }); - - runner.test('Should handle composite with transits and Seismograph', async () => { - const event = { httpMethod: 'POST', body: JSON.stringify({ - personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - transitParams: VALID_TRANSIT_PARAMS, - context: { mode: 'COMPOSITE_TRANSITS' }, - relationship_context: { type: 'PARTNER', intimacy_tier: 'P3' } - }) }; - const result = await handler(event); - runner.assertEqual(result.statusCode, 200); - const body = JSON.parse(result.body); - runner.assert(body.composite.aspects, 'Should have composite aspects'); - runner.assert(body.composite.transitsByDate, 'Should have composite transits'); - runner.assert(body.composite.transitsByDate['2024-01-01'].seismograph, 'Should have composite seismograph data'); - runner.assert(body.composite.derived.seismograph_summary, 'Composite should have seismograph summary'); - }); - - // Composite transits (explicit) – additional coverage - runner.test('Composite transits - explicit COMPOSITE_TRANSITS mode', async () => { - const result = await handler({ - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - context: { mode: 'COMPOSITE_TRANSITS' }, - relationship_context: { type: 'PARTNER', intimacy_tier: 'P3' }, - transitParams: { startDate: '2024-12-01', endDate: '2024-12-02' } - }) - }); - - runner.assertEqual(result.statusCode, 200, 'Should return 200 for composite transits'); - const response = JSON.parse(result.body); - runner.assert(response.composite, 'Should include composite data'); - runner.assert(response.composite.transitsByDate, 'Should include transitsByDate'); - runner.assert(response.composite.derived, 'Should include derived seismograph data'); - runner.assert(response.composite.derived.seismograph_summary, 'Should include seismograph summary'); - }); - - // Time policy tests for unknown birth time handling - runner.test('Time policy: planetary_only suppresses houses when birth time unknown', async () => { - const { handler } = loadModule(); - const A = { ...VALID_PERSON_A }; - delete A.hour; delete A.minute; // simulate unknown birth time - const event = { httpMethod: 'POST', body: JSON.stringify({ - personA: A, - context: { mode: 'NATAL_ASPECTS' }, // lean validation to allow missing time - time_policy: 'planetary_only', - transitParams: { startDate: '2024-01-01', endDate: '2024-01-01' } - }) }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 200, 'Response should be 200'); - const body = JSON.parse(res.body); - runner.assert(body.person_a, 'person_a present'); - runner.assert(body.person_a.meta, 'meta present'); - runner.assertEqual(body.person_a.meta.time_precision, 'unknown', 'time_precision should be unknown'); - runner.assert(body.person_a.houses_suppressed === true, 'houses should be suppressed under planetary_only'); - }); - - runner.test('Time policy: whole_sign allows houses with noon_fallback when birth time unknown', async () => { - const { handler } = loadModule(); - const A = { ...VALID_PERSON_A }; - delete A.hour; delete A.minute; // simulate unknown birth time - const event = { httpMethod: 'POST', body: JSON.stringify({ - personA: A, - context: { mode: 'NATAL_ASPECTS' }, // lean validation to allow missing time - time_policy: 'whole_sign', - transitParams: { startDate: '2024-01-01', endDate: '2024-01-01' } - }) }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 200, 'Response should be 200'); - const body = JSON.parse(res.body); - runner.assert(body.person_a, 'person_a present'); - runner.assert(body.person_a.meta, 'meta present'); - runner.assertEqual(body.person_a.meta.time_precision, 'noon_fallback', 'time_precision should be noon_fallback'); - runner.assertEqual(body.person_a.meta.effective_time_used, '12:00', 'effective_time_used should be 12:00'); - runner.assert(body.person_a.houses_suppressed !== true, 'houses should not be suppressed under whole_sign'); - }); - - runner.test('Translocation tz null when relocation not applied', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - context: { mode: 'NATAL_ASPECTS' }, - translocation: { - applies: false, - method: 'Custom', - tz: 'US/Central', - coords: { latitude: 41.8781, longitude: -87.6298 } - } - }) - }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 200, 'Response should be 200'); - const body = JSON.parse(res.body); - runner.assert(body.provenance, 'provenance present'); - runner.assertEqual(body.provenance.tz_authority, 'natal_record', 'tz_authority should stay natal when relocation is off'); - runner.assert(body.provenance.relocation_applied === false, 'relocation_applied should be false'); - runner.assert(body.context.translocation, 'translocation context present'); - runner.assert(body.context.translocation.tz === null, 'translocation tz should be null when relocation not applied'); - runner.assert(body.provenance.tz_conflict === false, 'tz_conflict should be false'); - runner.assert(body.provenance.geometry_ready === true, 'geometry should stay ready'); - runner.assertEqual(body.provenance.timezone, 'America/New_York', 'provenance timezone should remain natal'); - runner.assert(body.context.translocation.requested_tz === 'America/Chicago', 'requested tz should be normalized'); - }); - - runner.test('Relocation applied aligns tz authority and provenance tz', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - context: { mode: 'NATAL_ASPECTS' }, - custom_location: { - latitude: 41.8781, - longitude: -87.6298, - timezone: 'US/Central' - }, - translocation: { - applies: true, - method: 'Custom', - tz: 'US/Central', - coords: { latitude: 41.8781, longitude: -87.6298 } - } - }) + window: VALID_TRANSIT_PARAMS, + mode: 'NATAL_TRANSITS' }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 200, 'Response should be 200'); - const body = JSON.parse(res.body); - runner.assert(body.provenance.relocation_applied === true, 'relocation_applied should be true'); - runner.assertEqual(body.provenance.tz_authority, 'relocation_block', 'tz_authority should reflect relocation'); - runner.assert(body.context.translocation, 'translocation context present'); - runner.assert(body.context.translocation.tz, 'translocation tz should be present'); - runner.assertEqual(body.context.translocation.tz, body.provenance.timezone, 'translocation tz should match provenance'); - runner.assertEqual(body.provenance.timezone, 'America/Chicago', 'provenance timezone should use relocation tz'); - runner.assert(body.provenance.tz_conflict === false, 'tz_conflict should remain false'); - runner.assert(body.provenance.geometry_ready === true, 'geometry should remain ready'); - }); - - - runner.test('Both_local relocation applies shared coordinates for dyad', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - transitParams: VALID_TRANSIT_PARAMS, - context: { mode: 'SYNASTRY_TRANSITS' }, - relationship_context: { type: 'FRIEND', role: 'Acquaintance', contact_state: 'ACTIVE' }, - custom_location: { - latitude: 37.7749, - longitude: -122.4194, - timezone: 'America/Los_Angeles', - label: 'San Francisco, CA' - }, - translocation: { - applies: true, - method: 'Both_local', - tz: 'America/Los_Angeles', - current_location: 'San Francisco, CA', - coords: { latitude: 37.7749, longitude: -122.4194 } - } - }) - }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 200, 'Response should be 200'); - const body = JSON.parse(res.body); - runner.assert(body.provenance.relocation_applied === true, 'relocation should be applied'); - runner.assertEqual(body.provenance.relocation_mode, 'Both_local', 'provenance relocation mode should be Both_local'); - runner.assert(body.context.translocation, 'translocation context present'); - runner.assertEqual(body.context.translocation.method, 'Both_local', 'context method normalized'); - runner.assertEqual(body.context.translocation.houses_basis, 'relocation', 'houses basis should reflect relocation'); - runner.assertEqual(body.context.translocation.current_location, 'San Francisco, CA', 'location label preserved'); - runner.assert(body.context.translocation.coords, 'coords should be present'); - runner.assertEqual(body.context.translocation.coords.latitude, 37.7749, 'latitude preserved'); - runner.assertEqual(body.context.relocation_detail.person_a.relocation_mode, 'A_local', 'Person A relocation detail reflects local frame'); - runner.assertEqual(body.context.relocation_detail.person_b.relocation_mode, 'B_local', 'Person B relocation detail reflects local frame'); - runner.assert(body.footnotes.includes('Relocation mode: Both_local (houses recalculated).'), 'Both_local footnote should be present'); - }); - - runner.test('String token Both_local triggers relocation context', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - personB: VALID_PERSON_B, - transitParams: VALID_TRANSIT_PARAMS, - context: { mode: 'SYNASTRY_TRANSITS' }, - relationship_context: { type: 'FRIEND', role: 'Acquaintance', contact_state: 'ACTIVE' }, - custom_location: { - latitude: 37.7749, - longitude: -122.4194, - timezone: 'America/Los_Angeles', - label: 'San Francisco, CA' - }, - translocation: 'BOTH_LOCAL' - }) - }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 200, 'Response should be 200'); - const body = JSON.parse(res.body); - runner.assert(body.provenance.relocation_applied === true, 'relocation should apply for string token'); - runner.assertEqual(body.provenance.relocation_mode, 'Both_local', 'string token should normalize to Both_local'); - runner.assert(body.context.translocation, 'translocation context present'); - runner.assertEqual(body.context.translocation.method, 'Both_local', 'context method normalized from string'); - runner.assertEqual(body.context.translocation.houses_basis, 'relocation', 'houses basis should use relocation'); - runner.assertEqual(body.context.translocation.current_location, 'San Francisco, CA', 'label derived from custom location'); - }); - - runner.test('Mirror report rejects midpoint relocation mode', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - context: { mode: 'mirror' }, - translocation: { applies: true, method: 'Midpoint' } - }) - }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 400, 'should reject midpoint for mirror'); - const body = JSON.parse(res.body); - runner.assertEqual(body.code, 'RELOCATION_UNSUPPORTED', 'mirror midpoint error code'); - runner.assert(body.error.includes('Midpoint relocation'), 'mirror midpoint message'); - }); - - runner.test('Mirror report requires Person B for B_local relocation', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - context: { mode: 'mirror' }, - translocation: { applies: true, method: 'B_local' } - }) - }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 400, 'should reject B_local without Person B'); - const body = JSON.parse(res.body); - runner.assertEqual(body.code, 'invalid_relocation_mode_for_report', 'mirror B_local error code'); - }); - - runner.test('Balance report blocks midpoint without Person B', async () => { - const event = { - httpMethod: 'POST', - body: JSON.stringify({ - personA: VALID_PERSON_A, - context: { mode: 'balance_meter' }, - transitParams: VALID_TRANSIT_PARAMS, - translocation: { applies: true, method: 'Midpoint' } - }) - }; - const res = await handler(event); - runner.assertEqual(res.statusCode, 400, 'should reject midpoint without dyad'); - const body = JSON.parse(res.body); - runner.assertEqual(body.code, 'RELOCATION_UNSUPPORTED', 'balance midpoint error code'); - + const response = await fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + runner.assertEqual(response.status, 200); + const body = await response.json(); + runner.assert(body.person_a?.chart?.transitsByDate, 'Should have transitsByDate'); + const dayData = Object.values(body.person_a.chart.transitsByDate)[0]; + runner.assert(dayData.seismograph, 'Daily transit should have seismograph data'); + runner.assert(body.person_a.derived.seismograph_summary, 'Should have seismograph summary'); }); await runner.run();