diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index f52159926c..6d47119f45 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -25,8 +25,8 @@ import { app as clear_app_cache } from '../../supabase/functions/_backend/trigge import { app as clear_device_cache } from '../../supabase/functions/_backend/triggers/clear_device_cache.ts' import { app as cron_clear_versions } from '../../supabase/functions/_backend/triggers/cron_clear_versions.ts' import { app as cron_email } from '../../supabase/functions/_backend/triggers/cron_email.ts' -import { app as cron_plan } from '../../supabase/functions/_backend/triggers/cron_plan.ts' -import { app as cron_stats } from '../../supabase/functions/_backend/triggers/cron_stats.ts' +import { app as cron_stat_org } from '../../supabase/functions/_backend/triggers/cron_stat_org.ts' +import { app as cron_stat_app } from '../../supabase/functions/_backend/triggers/cron_stat_app.ts' import { app as logsnag_insights } from '../../supabase/functions/_backend/triggers/logsnag_insights.ts' import { app as on_app_create } from '../../supabase/functions/_backend/triggers/on_app_create.ts' import { app as on_channel_update } from '../../supabase/functions/_backend/triggers/on_channel_update.ts' @@ -96,8 +96,8 @@ appTriggers.route('/on_manifest_create', on_manifest_create) appTriggers.route('/on_deploy_history_create', on_deploy_history_create) appTriggers.route('/stripe_event', stripe_event) appTriggers.route('/on_organization_create', on_organization_create) -appTriggers.route('/cron_stats', cron_stats) -appTriggers.route('/cron_plan', cron_plan) +appTriggers.route('/cron_stat_app', cron_stat_app) +appTriggers.route('/cron_stat_org', cron_stat_org) appTriggers.route('/queue_consumer', queue_consumer) app.route('/triggers', appTriggers) diff --git a/netlify/edge-functions/triggers.ts b/netlify/edge-functions/triggers.ts index 4626917caf..6ff227b00e 100644 --- a/netlify/edge-functions/triggers.ts +++ b/netlify/edge-functions/triggers.ts @@ -9,8 +9,8 @@ import { Hono } from 'hono/tiny' import { app as clear_app_cache } from '../../supabase/functions/_backend/triggers/clear_app_cache.ts' import { app as clear_device_cache } from '../../supabase/functions/_backend/triggers/clear_device_cache.ts' import { app as cron_email } from '../../supabase/functions/_backend/triggers/cron_email.ts' -import { app as cron_plan } from '../../supabase/functions/_backend/triggers/cron_plan.ts' -import { app as cron_stats } from '../../supabase/functions/_backend/triggers/cron_stats.ts' +import { app as cron_stat_org } from '../../supabase/functions/_backend/triggers/cron_stat_org.ts' +import { app as cron_stat_app } from '../../supabase/functions/_backend/triggers/cron_stat_app.ts' import { app as logsnag_insights } from '../../supabase/functions/_backend/triggers/logsnag_insights.ts' import { app as on_channel_update } from '../../supabase/functions/_backend/triggers/on_channel_update.ts' import { app as on_deploy_history_create } from '../../supabase/functions/_backend/triggers/on_deploy_history_create.ts' @@ -50,8 +50,8 @@ appGlobal.route('/on_version_update', on_version_update) appGlobal.route('/on_version_delete', on_version_delete) appGlobal.route('/on_manifest_create', on_manifest_create) appGlobal.route('/stripe_event', stripe_event) -appGlobal.route('/cron_stats', cron_stats) -appGlobal.route('/cron_plan', cron_plan) +appGlobal.route('/cron_stat_app', cron_stat_app) +appGlobal.route('/cron_stat_org', cron_stat_org) appGlobal.route('/on_deploy_history_create', on_deploy_history_create) appGlobal.route('/queue_consumer', queue_consumer) diff --git a/src/components.d.ts b/src/components.d.ts index d837101048..9dabfccf7f 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -25,7 +25,6 @@ declare module 'vue' { DropdownProfile: typeof import('./components/dashboard/DropdownProfile.vue')['default'] FailedCard: typeof import('./components/FailedCard.vue')['default'] HistoryTable: typeof import('./components/tables/HistoryTable.vue')['default'] - IIonCopyOutline: typeof import('~icons/ion/copy-outline')['default'] InfoRow: typeof import('./components/package/InfoRow.vue')['default'] LangSelector: typeof import('./components/LangSelector.vue')['default'] LineChartStats: typeof import('./components/dashboard/LineChartStats.vue')['default'] diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 5cea3f2c0a..64c8e60504 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -773,11 +773,10 @@ export type Database = { Row: { apps: number apps_active: number | null + bundle_storage_gb: number created_at: string | null date_id: string devices_last_month: number | null - bundle_storage_gb: number - registers_today: number need_upgrade: number | null not_paying: number | null onboarded: number | null @@ -788,6 +787,7 @@ export type Database = { plan_payg: number | null plan_solo: number | null plan_team: number | null + registers_today: number stars: number success_rate: number | null trial: number | null @@ -800,11 +800,10 @@ export type Database = { Insert: { apps: number apps_active?: number | null + bundle_storage_gb?: number created_at?: string | null date_id: string devices_last_month?: number | null - bundle_storage_gb?: number - registers_today?: number need_upgrade?: number | null not_paying?: number | null onboarded?: number | null @@ -815,6 +814,7 @@ export type Database = { plan_payg?: number | null plan_solo?: number | null plan_team?: number | null + registers_today?: number stars: number success_rate?: number | null trial?: number | null @@ -827,11 +827,10 @@ export type Database = { Update: { apps?: number apps_active?: number | null + bundle_storage_gb?: number created_at?: string | null date_id?: string devices_last_month?: number | null - bundle_storage_gb?: number - registers_today?: number need_upgrade?: number | null not_paying?: number | null onboarded?: number | null @@ -842,6 +841,7 @@ export type Database = { plan_payg?: number | null plan_solo?: number | null plan_team?: number | null + registers_today?: number stars?: number success_rate?: number | null trial?: number | null @@ -1993,7 +1993,7 @@ export type Database = { Args: Record Returns: undefined } - queue_cron_plan_for_org: { + queue_cron_stat_org_for_org: { Args: { customer_id: string; org_id: string } Returns: undefined } diff --git a/supabase/functions/_backend/triggers/cron_stats.ts b/supabase/functions/_backend/triggers/cron_stat_app.ts similarity index 98% rename from supabase/functions/_backend/triggers/cron_stats.ts rename to supabase/functions/_backend/triggers/cron_stat_app.ts index e146b3ac19..8643bbce0f 100644 --- a/supabase/functions/_backend/triggers/cron_stats.ts +++ b/supabase/functions/_backend/triggers/cron_stat_app.ts @@ -17,7 +17,7 @@ app.use('/', useCors) app.post('/', middlewareAPISecret, async (c) => { const body = await parseBody(c) - cloudlog({ requestId: c.get('requestId'), message: 'post cron_stats body', body }) + cloudlog({ requestId: c.get('requestId'), message: 'post cron_stat_app body', body }) if (!body.appId) throw simpleError('no_appId', 'No appId', { body }) if (!body.orgId) @@ -101,7 +101,7 @@ app.post('/', middlewareAPISecret, async (c) => { if (!orgError && orgData?.customer_id) { // Queue plan processing for this organization - await supabase.rpc('queue_cron_plan_for_org', { + await supabase.rpc('queue_cron_stat_org_for_org', { org_id: body.orgId, customer_id: orgData.customer_id, }).throwOnError() diff --git a/supabase/functions/_backend/triggers/cron_plan.ts b/supabase/functions/_backend/triggers/cron_stat_org.ts similarity index 92% rename from supabase/functions/_backend/triggers/cron_plan.ts rename to supabase/functions/_backend/triggers/cron_stat_org.ts index d67e3f0f0b..a221cff1aa 100644 --- a/supabase/functions/_backend/triggers/cron_plan.ts +++ b/supabase/functions/_backend/triggers/cron_stat_org.ts @@ -14,7 +14,7 @@ export const app = new Hono() app.post('/', middlewareAPISecret, async (c) => { const body = await parseBody(c) - cloudlog({ requestId: c.get('requestId'), message: 'post cron plan body', body }) + cloudlog({ requestId: c.get('requestId'), message: 'post cron_stat_org body', body }) if (!body.orgId) throw simpleError('no_orgId', 'No orgId', { body }) diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 5cea3f2c0a..64c8e60504 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -773,11 +773,10 @@ export type Database = { Row: { apps: number apps_active: number | null + bundle_storage_gb: number created_at: string | null date_id: string devices_last_month: number | null - bundle_storage_gb: number - registers_today: number need_upgrade: number | null not_paying: number | null onboarded: number | null @@ -788,6 +787,7 @@ export type Database = { plan_payg: number | null plan_solo: number | null plan_team: number | null + registers_today: number stars: number success_rate: number | null trial: number | null @@ -800,11 +800,10 @@ export type Database = { Insert: { apps: number apps_active?: number | null + bundle_storage_gb?: number created_at?: string | null date_id: string devices_last_month?: number | null - bundle_storage_gb?: number - registers_today?: number need_upgrade?: number | null not_paying?: number | null onboarded?: number | null @@ -815,6 +814,7 @@ export type Database = { plan_payg?: number | null plan_solo?: number | null plan_team?: number | null + registers_today?: number stars: number success_rate?: number | null trial?: number | null @@ -827,11 +827,10 @@ export type Database = { Update: { apps?: number apps_active?: number | null + bundle_storage_gb?: number created_at?: string | null date_id?: string devices_last_month?: number | null - bundle_storage_gb?: number - registers_today?: number need_upgrade?: number | null not_paying?: number | null onboarded?: number | null @@ -842,6 +841,7 @@ export type Database = { plan_payg?: number | null plan_solo?: number | null plan_team?: number | null + registers_today?: number stars?: number success_rate?: number | null trial?: number | null @@ -1993,7 +1993,7 @@ export type Database = { Args: Record Returns: undefined } - queue_cron_plan_for_org: { + queue_cron_stat_org_for_org: { Args: { customer_id: string; org_id: string } Returns: undefined } diff --git a/supabase/functions/triggers/index.ts b/supabase/functions/triggers/index.ts index 43c287d3d9..97cd5fd91a 100644 --- a/supabase/functions/triggers/index.ts +++ b/supabase/functions/triggers/index.ts @@ -2,8 +2,8 @@ import { app as clear_app_cache } from '../_backend/triggers/clear_app_cache.ts' import { app as clear_device_cache } from '../_backend/triggers/clear_device_cache.ts' import { app as cron_clear_versions } from '../_backend/triggers/cron_clear_versions.ts' import { app as cron_email } from '../_backend/triggers/cron_email.ts' -import { app as cron_plan } from '../_backend/triggers/cron_plan.ts' -import { app as cron_stats } from '../_backend/triggers/cron_stats.ts' +import { app as cron_stat_app } from '../_backend/triggers/cron_stat_app.ts' +import { app as cron_stat_org } from '../_backend/triggers/cron_stat_org.ts' import { app as logsnag_insights } from '../_backend/triggers/logsnag_insights.ts' import { app as on_app_create } from '../_backend/triggers/on_app_create.ts' import { app as on_app_delete } from '../_backend/triggers/on_app_delete.ts' @@ -42,8 +42,8 @@ appGlobal.route('/on_version_delete', on_version_delete) appGlobal.route('/on_manifest_create', on_manifest_create) appGlobal.route('/stripe_event', stripe_event) appGlobal.route('/on_organization_create', on_organization_create) -appGlobal.route('/cron_stats', cron_stats) -appGlobal.route('/cron_plan', cron_plan) +appGlobal.route('/cron_stat_app', cron_stat_app) +appGlobal.route('/cron_stat_org', cron_stat_org) appGlobal.route('/cron_clear_versions', cron_clear_versions) appGlobal.route('/on_organization_delete', on_organization_delete) appGlobal.route('/on_deploy_history_create', on_deploy_history_create) diff --git a/supabase/migrations/20251014105957_rename_plan_cron.sql b/supabase/migrations/20251014105957_rename_plan_cron.sql new file mode 100644 index 0000000000..65b878dbe2 --- /dev/null +++ b/supabase/migrations/20251014105957_rename_plan_cron.sql @@ -0,0 +1,73 @@ +-- Simple renaming of cron_stats to cron_stat_app and cron_plan to cron_stat_org + +-- Unschedule existing cron jobs +SELECT cron.unschedule('process_cron_stats_queue'); +SELECT cron.unschedule('process_cron_stats_jobs'); +SELECT cron.unschedule('process_cron_plan_queue'); + +-- Rename the message queues +SELECT pgmq.drop_queue('cron_stats'); +SELECT pgmq.drop_queue('cron_plan'); +SELECT pgmq.create('cron_stat_app'); +SELECT pgmq.create('cron_stat_org'); + +-- Reschedule the cron jobs with new queue names +SELECT cron.schedule( + 'process_cron_stat_app_jobs', + '0 */6 * * *', + 'SELECT process_cron_stats_jobs();' +); + +SELECT cron.schedule( + 'process_cron_stat_app_queue', + '* * * * *', + 'SELECT public.process_function_queue(''cron_stat_app'')' +); + +SELECT cron.schedule( + 'process_cron_stat_org_queue', + '* * * * *', + 'SELECT public.process_function_queue(''cron_stat_org'')' +); + +-- Update the queue_cron_stat_org_for_org function to use the new queue name +CREATE OR REPLACE FUNCTION public.queue_cron_stat_org_for_org(org_id uuid, customer_id text) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + last_calculated timestamptz; +BEGIN + -- Check when plan was last calculated for this customer + SELECT plan_calculated_at INTO last_calculated + FROM public.stripe_info + WHERE stripe_info.customer_id = queue_cron_stat_org_for_org.customer_id; + + -- Only queue if plan wasn't calculated in the last hour + IF last_calculated IS NULL OR last_calculated < NOW() - INTERVAL '1 hour' THEN + PERFORM pgmq.send('cron_stat_org', + jsonb_build_object( + 'function_name', 'cron_stat_org', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'orgId', org_id, + 'customerId', customer_id + ) + ) + ); + END IF; +END; +$$; + +ALTER FUNCTION public.queue_cron_stat_org_for_org(uuid, text) OWNER TO postgres; + +-- Revoke all permissions first, then grant only to service_role +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid, text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid, text) FROM anon; +REVOKE ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid, text) FROM authenticated; +GRANT ALL ON FUNCTION public.queue_cron_stat_org_for_org(uuid, text) TO service_role; + +-- Drop the old function that is no longer needed +DROP FUNCTION IF EXISTS public.queue_cron_plan_for_org(uuid, text); \ No newline at end of file diff --git a/tests/cron_plan_integration.test.ts b/tests/cron_plan_integration.test.ts deleted file mode 100644 index 94abf5b16b..0000000000 --- a/tests/cron_plan_integration.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { BASE_URL, ORG_ID, USER_ID, getSupabaseClient, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts' - -const appId = `com.cron.plan.integration.${randomUUID()}` - -const triggerHeaders = { - 'Content-Type': 'application/json', - 'apisecret': 'testsecret', -} - -describe('[Integration] cron_stats -> cron_plan flow', () => { - beforeAll(async () => { - await resetAndSeedAppData(appId) - await resetAndSeedAppDataStats(appId) - - const supabase = getSupabaseClient() - - // Reset timestamps - await supabase - .from('orgs') - .update({ stats_updated_at: null }) - .eq('id', ORG_ID) - .throwOnError() - - // Reset plan calculated timestamp - await supabase - .from('stripe_info') - .update({ plan_calculated_at: null }) - .eq('customer_id', 'cus_Pa0k8TO6HVln6A') // From seed data - .throwOnError() - }) - - afterAll(async () => { - await resetAppData(appId) - await resetAppDataStats(appId) - }) - - it('cron_stats triggers plan processing and updates plan_calculated_at', async () => { - const supabase = getSupabaseClient() - - // First, get the actual customer_id for our test org - const { data: orgData } = await supabase - .from('orgs') - .select('customer_id') - .eq('id', ORG_ID) - .single() - .throwOnError() - - console.log('Test org customer_id:', orgData?.customer_id) - - // Skip test if no customer_id (this org doesn't have stripe setup) - if (!orgData?.customer_id) { - console.log('Skipping test - org has no customer_id') - return - } - - // Reset plan_calculated_at to null for this customer - await supabase - .from('stripe_info') - .update({ plan_calculated_at: null }) - .eq('customer_id', orgData.customer_id) - .throwOnError() - - // Verify initial state - no plan_calculated_at - const { data: initialStripeInfo } = await supabase - .from('stripe_info') - .select('plan_calculated_at') - .eq('customer_id', orgData.customer_id) - .single() - .throwOnError() - - expect(initialStripeInfo?.plan_calculated_at).toBeNull() - - // Trigger cron_stats which should queue plan processing - const statsResponse = await fetch(`${BASE_URL}/triggers/cron_stats`, { - method: 'POST', - headers: triggerHeaders, - body: JSON.stringify({ - appId, - orgId: ORG_ID, - }), - }) - - expect(statsResponse.status).toBe(200) - const statsJson = await statsResponse.json() as { status?: string } - expect(statsJson.status).toBe('Stats saved') - - // Verify stats_updated_at was set - const { data: org } = await supabase - .from('orgs') - .select('stats_updated_at') - .eq('id', ORG_ID) - .single() - .throwOnError() - - expect(org?.stats_updated_at).toBeTruthy() - - // Check that a plan job was queued (we can't easily test queue contents, but we can verify the function doesn't error) - // The plan processing would normally be triggered by the queue processor - - // Manually trigger cron_plan to simulate queue processing - const planResponse = await fetch(`${BASE_URL}/triggers/cron_plan`, { - method: 'POST', - headers: triggerHeaders, - body: JSON.stringify({ - orgId: ORG_ID, - customerId: orgData.customer_id, - }), - }) - - expect(planResponse.status).toBe(200) - - // Verify plan_calculated_at was updated - const { data: updatedStripeInfo } = await supabase - .from('stripe_info') - .select('plan_calculated_at') - .eq('customer_id', orgData.customer_id) - .single() - .throwOnError() - - expect(updatedStripeInfo?.plan_calculated_at).toBeTruthy() - - const timestamp = updatedStripeInfo?.plan_calculated_at - const updatedAtMs = new Date(timestamp!).getTime() - expect(Number.isNaN(updatedAtMs)).toBe(false) - - const diffMs = Math.abs(Date.now() - updatedAtMs) - expect(diffMs).toBeLessThan(60_000) // Within last minute - }) - - it('rate limiting prevents duplicate plan processing within 1 hour', async () => { - const supabase = getSupabaseClient() - - // Get the actual customer_id for our test org - const { data: orgData } = await supabase - .from('orgs') - .select('customer_id') - .eq('id', ORG_ID) - .single() - .throwOnError() - - // Skip test if no customer_id - if (!orgData?.customer_id) { - console.log('Skipping test - org has no customer_id') - return - } - - // Set plan_calculated_at to 30 minutes ago (within 1 hour) - const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000) - await supabase - .from('stripe_info') - .update({ plan_calculated_at: thirtyMinutesAgo.toISOString() }) - .eq('customer_id', orgData.customer_id) - .throwOnError() - - // Call the queue function directly (simulating what cron_stats does) - const { error } = await supabase.rpc('queue_cron_plan_for_org', { - org_id: ORG_ID, - customer_id: orgData.customer_id - }) - - // Should not error (rate limiting should silently skip) - expect(error).toBeNull() - - // The timestamp should remain unchanged (not updated) - const { data: stripeInfo } = await supabase - .from('stripe_info') - .select('plan_calculated_at') - .eq('customer_id', orgData.customer_id) - .single() - .throwOnError() - - const actualTimestamp = new Date(stripeInfo?.plan_calculated_at!).getTime() - const expectedTimestamp = thirtyMinutesAgo.getTime() - - // Should be within 1 second of the original timestamp (accounting for precision) - expect(Math.abs(actualTimestamp - expectedTimestamp)).toBeLessThan(1000) - }) - - it('allows plan processing after 1 hour has passed', async () => { - const supabase = getSupabaseClient() - - // Get the actual customer_id for our test org - const { data: orgData } = await supabase - .from('orgs') - .select('customer_id') - .eq('id', ORG_ID) - .single() - .throwOnError() - - // Skip test if no customer_id - if (!orgData?.customer_id) { - console.log('Skipping test - org has no customer_id') - return - } - - // Set plan_calculated_at to 2 hours ago (outside 1 hour window) - const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000) - await supabase - .from('stripe_info') - .update({ plan_calculated_at: twoHoursAgo.toISOString() }) - .eq('customer_id', orgData.customer_id) - .throwOnError() - - // Call the queue function directly - const { error } = await supabase.rpc('queue_cron_plan_for_org', { - org_id: ORG_ID, - customer_id: orgData.customer_id - }) - - expect(error).toBeNull() - - // Now manually trigger plan processing to simulate queue processing - const planResponse = await fetch(`${BASE_URL}/triggers/cron_plan`, { - method: 'POST', - headers: triggerHeaders, - body: JSON.stringify({ - orgId: ORG_ID, - customerId: orgData.customer_id, - }), - }) - - expect(planResponse.status).toBe(200) - - // Verify plan_calculated_at was updated to recent time - const { data: stripeInfo } = await supabase - .from('stripe_info') - .select('plan_calculated_at') - .eq('customer_id', orgData.customer_id) - .single() - .throwOnError() - - const timestamp = stripeInfo?.plan_calculated_at - const updatedAtMs = new Date(timestamp!).getTime() - const diffMs = Math.abs(Date.now() - updatedAtMs) - - // Should be updated to within the last minute - expect(diffMs).toBeLessThan(60_000) - }) - - it('handles missing customer_id gracefully', async () => { - // Trigger cron_stats for an org without customer_id - const supabase = getSupabaseClient() - - // Create a temporary org without customer_id - const tempOrgId = randomUUID() - await supabase - .from('orgs') - .insert({ - id: tempOrgId, - name: 'Test Org No Customer', - management_email: 'test@example.com', - created_by: USER_ID, - }) - .throwOnError() - - // Create app for this org - const tempAppId = `com.test.nocustomer.${randomUUID()}` - await supabase - .from('apps') - .insert({ - app_id: tempAppId, - owner_org: tempOrgId, - name: 'Test App No Customer', - icon_url: 'https://example.com/icon.png', - }) - .throwOnError() - - // Create app version - await supabase - .from('app_versions') - .insert({ - app_id: tempAppId, - name: '1.0.0', - owner_org: tempOrgId, - }) - .throwOnError() - - // Trigger cron_stats - should not error even without customer_id - const statsResponse = await fetch(`${BASE_URL}/triggers/cron_stats`, { - method: 'POST', - headers: triggerHeaders, - body: JSON.stringify({ - appId: tempAppId, - orgId: tempOrgId, - }), - }) - - expect(statsResponse.status).toBe(200) - - // Clean up - await supabase.from('app_versions').delete().eq('app_id', tempAppId).throwOnError() - await supabase.from('apps').delete().eq('app_id', tempAppId).throwOnError() - await supabase.from('orgs').delete().eq('id', tempOrgId).throwOnError() - }) -}) diff --git a/tests/cron_stats.test.ts b/tests/cron_stat_app.test.ts similarity index 95% rename from tests/cron_stats.test.ts rename to tests/cron_stat_app.test.ts index 08a87a4380..3d71670c33 100644 --- a/tests/cron_stats.test.ts +++ b/tests/cron_stat_app.test.ts @@ -2,14 +2,14 @@ import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { BASE_URL, ORG_ID, getSupabaseClient, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts' -const appId = `com.cron.stats.${randomUUID()}` +const appId = `com.cron.${randomUUID().slice(0, 8)}` const triggerHeaders = { 'Content-Type': 'application/json', 'apisecret': 'testsecret', } -describe('[POST] /triggers/cron_stats', () => { +describe('[POST] /triggers/cron_stat_app', () => { beforeAll(async () => { await resetAndSeedAppData(appId) await resetAndSeedAppDataStats(appId) @@ -29,7 +29,7 @@ describe('[POST] /triggers/cron_stats', () => { }) it('updates stats_updated_at with a fresh timestamp', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_stats`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { method: 'POST', headers: triggerHeaders, body: JSON.stringify({ @@ -72,7 +72,7 @@ describe('[POST] /triggers/cron_stats', () => { .eq('customer_id', 'cus_Pa0k8TO6HVln6A') // From seed data .throwOnError() - const response = await fetch(`${BASE_URL}/triggers/cron_stats`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { method: 'POST', headers: triggerHeaders, body: JSON.stringify({ @@ -87,7 +87,7 @@ describe('[POST] /triggers/cron_stats', () => { // Verify that the queue function can be called (indicates plan processing was queued) // We can't easily check queue contents, but we can verify the function works - const { error: queueError } = await supabase.rpc('queue_cron_plan_for_org', { + const { error: queueError } = await supabase.rpc('queue_cron_stat_org_for_org', { org_id: ORG_ID, customer_id: 'cus_Pa0k8TO6HVln6A' }) diff --git a/tests/cron_stat_integration.test.ts b/tests/cron_stat_integration.test.ts new file mode 100644 index 0000000000..32b790bcfb --- /dev/null +++ b/tests/cron_stat_integration.test.ts @@ -0,0 +1,297 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { BASE_URL, getSupabaseClient, ORG_ID, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats, USER_ID } from './test-utils.ts' + +const appId = `com.cron.${randomUUID().slice(0, 8)}` + +const triggerHeaders = { + 'Content-Type': 'application/json', + 'apisecret': 'testsecret', +} + +describe('[Integration] cron_stat_app -> cron_stat_org flow', () => { + beforeAll(async () => { + await resetAndSeedAppData(appId) + await resetAndSeedAppDataStats(appId) + + const supabase = getSupabaseClient() + + // Reset timestamps + await supabase + .from('orgs') + .update({ stats_updated_at: null }) + .eq('id', ORG_ID) + .throwOnError() + + // Reset plan calculated timestamp + await supabase + .from('stripe_info') + .update({ plan_calculated_at: null }) + .eq('customer_id', 'cus_Pa0k8TO6HVln6A') // From seed data + .throwOnError() + }) + + afterAll(async () => { + await resetAppData(appId) + await resetAppDataStats(appId) + }) + + it('cron_stat_app triggers plan processing and updates plan_calculated_at', async () => { + const supabase = getSupabaseClient() + + // First, get the actual customer_id for our test org + const { data: orgData } = await supabase + .from('orgs') + .select('customer_id') + .eq('id', ORG_ID) + .single() + .throwOnError() + + console.log('Test org customer_id:', orgData?.customer_id) + + // Skip test if no customer_id (this org doesn't have stripe setup) + if (!orgData?.customer_id) { + console.log('Skipping test - org has no customer_id') + return + } + + // Reset plan_calculated_at to null for this customer + await supabase + .from('stripe_info') + .update({ plan_calculated_at: null }) + .eq('customer_id', orgData.customer_id) + .throwOnError() + + // Verify initial state - no plan_calculated_at + const { data: initialStripeInfo } = await supabase + .from('stripe_info') + .select('plan_calculated_at') + .eq('customer_id', orgData.customer_id) + .single() + .throwOnError() + + expect(initialStripeInfo?.plan_calculated_at).toBeNull() + + // Trigger cron_stat_app which should queue plan processing + const statsResponse = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { + method: 'POST', + headers: triggerHeaders, + body: JSON.stringify({ + appId, + orgId: ORG_ID, + }), + }) + + expect(statsResponse.status).toBe(200) + const statsJson = await statsResponse.json() as { status?: string } + expect(statsJson.status).toBe('Stats saved') + + // Verify stats_updated_at was set + const { data: org } = await supabase + .from('orgs') + .select('stats_updated_at') + .eq('id', ORG_ID) + .single() + .throwOnError() + + expect(org?.stats_updated_at).toBeTruthy() + + // Check that a plan job was queued (we can't easily test queue contents, but we can verify the function doesn't error) + // The plan processing would normally be triggered by the queue processor + + // Manually trigger cron_plan to simulate queue processing + const planResponse = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { + method: 'POST', + headers: triggerHeaders, + body: JSON.stringify({ + orgId: ORG_ID, + customerId: orgData.customer_id, + }), + }) + + expect(planResponse.status).toBe(200) + + // Verify plan_calculated_at was updated + const { data: updatedStripeInfo } = await supabase + .from('stripe_info') + .select('plan_calculated_at') + .eq('customer_id', orgData.customer_id) + .single() + .throwOnError() + + expect(updatedStripeInfo?.plan_calculated_at).toBeTruthy() + + const timestamp = updatedStripeInfo?.plan_calculated_at + const updatedAtMs = new Date(timestamp!).getTime() + expect(Number.isNaN(updatedAtMs)).toBe(false) + + const diffMs = Math.abs(Date.now() - updatedAtMs) + expect(diffMs).toBeLessThan(60_000) // Within last minute + }) + + it('rate limiting prevents duplicate plan processing within 1 hour', async () => { + const supabase = getSupabaseClient() + + // Get the actual customer_id for our test org + const { data: orgData } = await supabase + .from('orgs') + .select('customer_id') + .eq('id', ORG_ID) + .single() + .throwOnError() + + // Skip test if no customer_id + if (!orgData?.customer_id) { + console.log('Skipping test - org has no customer_id') + return + } + + // Set plan_calculated_at to 30 minutes ago (within 1 hour) + const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000) + await supabase + .from('stripe_info') + .update({ plan_calculated_at: thirtyMinutesAgo.toISOString() }) + .eq('customer_id', orgData.customer_id) + .throwOnError() + + // Call the queue function directly (simulating what cron_stat_app does) + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { + org_id: ORG_ID, + customer_id: orgData.customer_id, + }) + + // Should not error (rate limiting should silently skip) + expect(error).toBeNull() + + // The timestamp should remain unchanged (not updated) + const { data: stripeInfo } = await supabase + .from('stripe_info') + .select('plan_calculated_at') + .eq('customer_id', orgData.customer_id) + .single() + .throwOnError() + + const actualTimestamp = new Date(stripeInfo?.plan_calculated_at ?? 0).getTime() + const expectedTimestamp = thirtyMinutesAgo.getTime() + + // Should be within 1 second of the original timestamp (accounting for precision) + expect(Math.abs(actualTimestamp - expectedTimestamp)).toBeLessThan(1000) + }) + + it('allows plan processing after 1 hour has passed', async () => { + const supabase = getSupabaseClient() + + // Get the actual customer_id for our test org + const { data: orgData } = await supabase + .from('orgs') + .select('customer_id') + .eq('id', ORG_ID) + .single() + .throwOnError() + + // Skip test if no customer_id + if (!orgData?.customer_id) { + console.log('Skipping test - org has no customer_id') + return + } + + // Set plan_calculated_at to 2 hours ago (outside 1 hour window) + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000) + await supabase + .from('stripe_info') + .update({ plan_calculated_at: twoHoursAgo.toISOString() }) + .eq('customer_id', orgData.customer_id) + .throwOnError() + + // Call the queue function directly + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { + org_id: ORG_ID, + customer_id: orgData.customer_id, + }) + + expect(error).toBeNull() + + // Now manually trigger plan processing to simulate queue processing + const planResponse = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { + method: 'POST', + headers: triggerHeaders, + body: JSON.stringify({ + orgId: ORG_ID, + customerId: orgData.customer_id, + }), + }) + + expect(planResponse.status).toBe(200) + + // Verify plan_calculated_at was updated to recent time + const { data: stripeInfo } = await supabase + .from('stripe_info') + .select('plan_calculated_at') + .eq('customer_id', orgData.customer_id) + .single() + .throwOnError() + + const timestamp = stripeInfo?.plan_calculated_at + const updatedAtMs = new Date(timestamp!).getTime() + const diffMs = Math.abs(Date.now() - updatedAtMs) + + // Should be updated to within the last minute + expect(diffMs).toBeLessThan(60_000) + }) + + it('handles missing customer_id gracefully', async () => { + // Trigger cron_stat_app for an org without customer_id + const supabase = getSupabaseClient() + + // Create a temporary org without customer_id + const tempOrgId = randomUUID() + await supabase + .from('orgs') + .insert({ + id: tempOrgId, + name: 'Test Org No Customer', + management_email: 'test@example.com', + created_by: USER_ID, + }) + .throwOnError() + + // Create app for this org + const tempAppId = `com.test.nocustomer.${randomUUID()}` + await supabase + .from('apps') + .insert({ + app_id: tempAppId, + owner_org: tempOrgId, + name: 'Test App No Customer', + icon_url: 'https://example.com/icon.png', + }) + .throwOnError() + + // Create app version + await supabase + .from('app_versions') + .insert({ + app_id: tempAppId, + name: '1.0.0', + owner_org: tempOrgId, + }) + .throwOnError() + + // Trigger cron_stat_app - should not error even without customer_id + const statsResponse = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { + method: 'POST', + headers: triggerHeaders, + body: JSON.stringify({ + appId: tempAppId, + orgId: tempOrgId, + }), + }) + + expect(statsResponse.status).toBe(200) + + // Clean up + await supabase.from('app_versions').delete().eq('app_id', tempAppId).throwOnError() + await supabase.from('apps').delete().eq('app_id', tempAppId).throwOnError() + await supabase.from('orgs').delete().eq('id', tempOrgId).throwOnError() + }) +}) diff --git a/tests/cron_plan.test.ts b/tests/cron_stat_org.test.ts similarity index 95% rename from tests/cron_plan.test.ts rename to tests/cron_stat_org.test.ts index 705e4628e6..640b6f48d0 100644 --- a/tests/cron_plan.test.ts +++ b/tests/cron_stat_org.test.ts @@ -112,9 +112,9 @@ afterAll(async () => { await resetAppDataStats(APPNAME) }) -describe('[POST] /triggers/cron_plan', () => { +describe('[POST] /triggers/cron_stat_org', () => { it('should return 400 when orgId is missing', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({}), @@ -150,7 +150,7 @@ describe('[POST] /triggers/cron_plan', () => { if (error) throw error - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -218,7 +218,7 @@ describe('[POST] /triggers/cron_plan', () => { if (error) throw error - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -295,7 +295,7 @@ describe('[POST] /triggers/cron_plan', () => { if (error) throw error - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -366,7 +366,7 @@ describe('[POST] /triggers/cron_plan', () => { expect(setMauError).toBeFalsy() // Run cron plan to set exceeded status - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -395,7 +395,7 @@ describe('[POST] /triggers/cron_plan', () => { expect(appMetricsCacheError).toBeFalsy() // Run cron plan again - const response2 = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response2 = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -420,7 +420,7 @@ describe('[POST] /triggers/cron_plan', () => { expect(setStorageError).toBeFalsy() // Run cron plan to set exceeded status - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -448,7 +448,7 @@ describe('[POST] /triggers/cron_plan', () => { expect(storageCacheError).toBeFalsy() // Run cron plan again - const response2 = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response2 = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -482,7 +482,7 @@ describe('[POST] /triggers/cron_plan', () => { expect(setBandwidthError).toBeFalsy() // Run cron plan to set exceeded status - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -511,7 +511,7 @@ describe('[POST] /triggers/cron_plan', () => { expect(appMetricsCacheError).toBeFalsy() // Run cron plan again - const response2 = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response2 = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers, body: JSON.stringify({ orgId: ORG_ID }), @@ -564,7 +564,7 @@ describe('[POST] /triggers/cron_plan', () => { // expect(deletedApp).toBeTruthy() // // Wait for the trigger to process by calling cron_plan - // const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + // const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { // method: 'POST', // headers, // body: JSON.stringify({ orgId: ORG_ID }), diff --git a/tests/error-cases.test.ts b/tests/error-cases.test.ts index 8c8337a422..9015f50994 100644 --- a/tests/error-cases.test.ts +++ b/tests/error-cases.test.ts @@ -194,8 +194,8 @@ describe('server Error Cases (5xx)', () => { }) describe('trigger Endpoint Error Cases', () => { - it('should return 400 for cron_stats without appId', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_stats`, { + it('should return 400 for cron_stat_app without appId', async () => { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -209,7 +209,7 @@ describe('trigger Endpoint Error Cases', () => { }) it('should return 400 for cron_plan without orgId', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/tests/queue_cron_plan_function.test.ts b/tests/queue_cron_stat_org_function.test.ts similarity index 84% rename from tests/queue_cron_plan_function.test.ts rename to tests/queue_cron_stat_org_function.test.ts index 12a42a6b3d..795ec5871b 100644 --- a/tests/queue_cron_plan_function.test.ts +++ b/tests/queue_cron_stat_org_function.test.ts @@ -1,7 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { ORG_ID, getSupabaseClient, getCronPlanQueueCount, getLatestCronPlanMessage, cleanupPostgresClient } from './test-utils.ts' +import { cleanupPostgresClient, getCronPlanQueueCount, getLatestCronPlanMessage, getSupabaseClient, ORG_ID } from './test-utils.ts' -describe('[Function] queue_cron_plan_for_org', () => { + +describe('[Function] queue_cron_stat_org_for_org', () => { let testCustomerId: string | null = null beforeAll(async () => { @@ -16,7 +17,8 @@ describe('[Function] queue_cron_plan_for_org', () => { if (orgData?.customer_id) { testCustomerId = orgData.customer_id - } else { + } + else { // Fallback: get any existing stripe_info record const { data: stripeData } = await supabase .from('stripe_info') @@ -52,9 +54,9 @@ describe('[Function] queue_cron_plan_for_org', () => { const initialCount = await getCronPlanQueueCount() // Call the function - const { error } = await supabase.rpc('queue_cron_plan_for_org', { + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { org_id: ORG_ID, - customer_id: testCustomerId + customer_id: testCustomerId, }) expect(error).toBeNull() @@ -66,12 +68,12 @@ describe('[Function] queue_cron_plan_for_org', () => { // Verify the queue record contains correct data const latestMessage = await getLatestCronPlanMessage() expect(latestMessage).toMatchObject({ - function_name: 'cron_plan', + function_name: 'cron_stat_org', function_type: 'cloudflare', payload: { orgId: ORG_ID, - customerId: testCustomerId - } + customerId: testCustomerId, + }, }) }) @@ -95,9 +97,9 @@ describe('[Function] queue_cron_plan_for_org', () => { const initialCount = await getCronPlanQueueCount() // Call the function - const { error } = await supabase.rpc('queue_cron_plan_for_org', { + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { org_id: ORG_ID, - customer_id: testCustomerId + customer_id: testCustomerId, }) expect(error).toBeNull() @@ -114,7 +116,7 @@ describe('[Function] queue_cron_plan_for_org', () => { .single() .throwOnError() - const actualTimestamp = new Date(stripeInfo?.plan_calculated_at!).getTime() + const actualTimestamp = new Date(stripeInfo?.plan_calculated_at ?? 0).getTime() const expectedTimestamp = thirtyMinutesAgo.getTime() // Should be within 1 second of the original timestamp (rate limiting prevented update) @@ -141,9 +143,9 @@ describe('[Function] queue_cron_plan_for_org', () => { const initialCount = await getCronPlanQueueCount() // Call the function - const { error } = await supabase.rpc('queue_cron_plan_for_org', { + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { org_id: ORG_ID, - customer_id: testCustomerId + customer_id: testCustomerId, }) expect(error).toBeNull() @@ -155,12 +157,12 @@ describe('[Function] queue_cron_plan_for_org', () => { // Verify the queue record contains correct data const latestMessage = await getLatestCronPlanMessage() expect(latestMessage).toMatchObject({ - function_name: 'cron_plan', + function_name: 'cron_stat_org', function_type: 'cloudflare', payload: { orgId: ORG_ID, - customerId: testCustomerId - } + customerId: testCustomerId, + }, }) }) @@ -168,9 +170,9 @@ describe('[Function] queue_cron_plan_for_org', () => { const supabase = getSupabaseClient() // Call with non-existent customer_id - const { error } = await supabase.rpc('queue_cron_plan_for_org', { + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { org_id: ORG_ID, - customer_id: 'non_existent_customer' + customer_id: 'non_existent_customer', }) expect(error).toBeNull() @@ -187,9 +189,9 @@ describe('[Function] queue_cron_plan_for_org', () => { // The actual permission restriction is tested at the database level const supabase = getSupabaseClient() - const { error } = await supabase.rpc('queue_cron_plan_for_org', { + const { error } = await supabase.rpc('queue_cron_stat_org_for_org', { org_id: ORG_ID, - customer_id: testCustomerId + customer_id: testCustomerId, }) expect(error).toBeNull() diff --git a/tests/queue_load.test.ts b/tests/queue_load.test.ts index 85b98d7a85..32fb136838 100644 --- a/tests/queue_load.test.ts +++ b/tests/queue_load.test.ts @@ -243,7 +243,7 @@ describe('queue Load Test', () => { // fetch(`${BASE_URL_TRIGGER}/queue_consumer/sync`, { // method: 'POST', // headers: headersInternal, - // body: JSON.stringify({ queue_name: 'cron_stats' }), + // body: JSON.stringify({ queue_name: 'cron_stat_app' }), // }), // ) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 46d4772b9f..4797f37e5d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -372,12 +372,12 @@ export async function executeSQL(query: string, params?: any[]): Promise { } export async function getCronPlanQueueCount(): Promise { - const result = await executeSQL('SELECT COUNT(*) as count FROM pgmq.q_cron_plan') + const result = await executeSQL('SELECT COUNT(*) as count FROM pgmq.q_cron_stat_org') return parseInt(result[0]?.count || '0') } export async function getLatestCronPlanMessage(): Promise { - const result = await executeSQL('SELECT message FROM pgmq.q_cron_plan ORDER BY msg_id DESC LIMIT 1') + const result = await executeSQL('SELECT message FROM pgmq.q_cron_stat_org ORDER BY msg_id DESC LIMIT 1') return result[0]?.message } diff --git a/tests/trigger-error-cases.test.ts b/tests/trigger-error-cases.test.ts index e90598a740..8e26ea3864 100644 --- a/tests/trigger-error-cases.test.ts +++ b/tests/trigger-error-cases.test.ts @@ -18,9 +18,9 @@ afterAll(async () => { await resetAppData(APPNAME) }) -describe('[POST] /triggers/cron_stats - Error Cases', () => { +describe('[POST] /triggers/cron_stat_app - Error Cases', () => { it('should return 400 when appId is missing', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_stats`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { method: 'POST', headers: triggerHeaders, body: JSON.stringify({}), @@ -31,7 +31,7 @@ describe('[POST] /triggers/cron_stats - Error Cases', () => { }) it('should return 400 when org is missing', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_stats`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { method: 'POST', headers: triggerHeaders, body: JSON.stringify({ @@ -44,7 +44,7 @@ describe('[POST] /triggers/cron_stats - Error Cases', () => { }) it('should return 400 when appId is not provided', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_stats`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_app`, { method: 'POST', headers: triggerHeaders, body: JSON.stringify({ @@ -57,9 +57,9 @@ describe('[POST] /triggers/cron_stats - Error Cases', () => { }) }) -describe('[POST] /triggers/cron_plan - Error Cases', () => { +describe('[POST] /triggers/cron_stat_org - Error Cases', () => { it('should return 400 when orgId is missing', async () => { - const response = await fetch(`${BASE_URL}/triggers/cron_plan`, { + const response = await fetch(`${BASE_URL}/triggers/cron_stat_org`, { method: 'POST', headers: triggerHeaders, body: JSON.stringify({}),