diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 63acf58..e926068 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -1,9 +1,14 @@ import postgres from 'postgres' -import { afterAll, afterEach, beforeAll, beforeEach, inject } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, inject, vi } from 'vitest' import { resetFactories } from '../factories' import { server } from '../msw/server' import { applyPlaceholderServerEnv } from '../support/placeholder-env' +// Run tasks inline via the double so tests drive the real graph (no Trigger server). +// Both specifiers: tasks use `/v3`, withErrorLogger uses the base import. +vi.mock('@trigger.dev/sdk/v3', () => import('../trigger/inlineSdk')) +vi.mock('@trigger.dev/sdk', () => import('../trigger/inlineSdk')) + // Runs in every worker BEFORE any test file imports `@/db`. Point the app's DB // singleton (`src/db/index.ts` reads `env.DATABASE_URL` at import) at the // container, and satisfy the rest of the server-env Zod schema with placeholders. diff --git a/test/trigger/index.ts b/test/trigger/index.ts new file mode 100644 index 0000000..7f7a592 --- /dev/null +++ b/test/trigger/index.ts @@ -0,0 +1 @@ +export * from './inlineSdk' diff --git a/test/trigger/inlineSdk.integration.test.ts b/test/trigger/inlineSdk.integration.test.ts new file mode 100644 index 0000000..f1c9445 --- /dev/null +++ b/test/trigger/inlineSdk.integration.test.ts @@ -0,0 +1,30 @@ +import { task } from '@trigger.dev/sdk/v3' +import { describe, expect, it } from 'vitest' +import { processDropboxChanges } from '@/trigger/processFileSync' + +// Load-bearing on the setup-level vi.mock: unmocked, the live SDK's trigger methods +// throw "can only be used from inside a task.run()". + +describe('inline SDK double in the integration project', () => { + it('the doubled task() runs run() inline and resolves a TaskRunResult', async () => { + const ran: string[] = [] + // returns a Promise to satisfy the real `run` type (tsc sees the real SDK). + const probe = task({ + id: 'inline-double-probe', + run: (payload: string) => { + ran.push(payload) + return Promise.resolve() + }, + }) + const result = await probe.triggerAndWait('hit') + expect(ran).toEqual(['hit']) + expect(result).toMatchObject({ ok: true, output: undefined }) + }) + + it('drives a REAL exported task through the double, in-process, against Postgres', async () => { + // No active connection in a truncated DB → fetchDropBoxChanges logs + returns early + // (no MSW calls, no throw). Proves a real task ran inline via the double. + const result = await processDropboxChanges.triggerAndWait('no-such-account') + expect(result).toMatchObject({ ok: true }) + }) +}) diff --git a/test/trigger/inlineSdk.test.ts b/test/trigger/inlineSdk.test.ts new file mode 100644 index 0000000..179abd5 --- /dev/null +++ b/test/trigger/inlineSdk.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, logger, schedules, task } from './inlineSdk' + +describe('inline task double', () => { + it('trigger runs the task run() inline and resolves after it completes', async () => { + const calls: number[] = [] + // genuinely async run: proves the double awaits it before resolving. + const run = vi.fn(async (n: number) => { + await Promise.resolve() + calls.push(n) + }) + const t = task({ id: 't', run }) + await t.trigger(5) + expect(run).toHaveBeenCalledWith(5) + expect(calls).toEqual([5]) + }) + + it('trigger ignores the options argument (e.g. concurrencyKey)', async () => { + const run = vi.fn(() => Promise.resolve()) + const t = task({ id: 't', run }) + await t.trigger('acct', { concurrencyKey: 'acct' }) + expect(run).toHaveBeenCalledTimes(1) + expect(run).toHaveBeenCalledWith('acct') // second arg not forwarded + }) + + it('triggerAndWait resolves a { ok: true, output } TaskRunResult on success', async () => { + const t = task({ id: 't', run: (p: { x: number }) => p.x * 2 }) + const res = await t.triggerAndWait({ x: 3 }) + expect(res).toMatchObject({ ok: true, output: 6, taskIdentifier: 't' }) + }) + + it('triggerAndWait RESOLVES { ok: false, error } when run throws — it does not reject', async () => { + const boom = new Error('boom') + const t = task({ + id: 't', + run: () => { + throw boom + }, + }) + // must resolve, not reject: real *AndWait absorbs run failures into the result. + const res = await t.triggerAndWait(undefined) + expect(res).toMatchObject({ ok: false, error: boom, taskIdentifier: 't' }) + }) + + it('batchTriggerAndWait unwraps { payload } items and runs each in order', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + }, + }) + const res = await t.batchTriggerAndWait([{ payload: 'a' }, { payload: 'b' }]) + expect(seen).toEqual(['a', 'b']) + expect(res.runs.map((r) => r.ok)).toEqual([true, true]) + }) + + it('batchTriggerAndWait runs EVERY item even when one throws, reporting per-item results', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + if (p === 'b') throw new Error('bad b') + }, + }) + const res = await t.batchTriggerAndWait([{ payload: 'a' }, { payload: 'b' }, { payload: 'c' }]) + expect(seen).toEqual(['a', 'b', 'c']) // b throwing did not skip c + expect(res.runs.map((r) => r.ok)).toEqual([true, false, true]) + }) + + it('batchTrigger unwraps { payload } items and runs each', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + }, + }) + await t.batchTrigger([{ payload: 'x' }, { payload: 'y' }]) + expect(seen).toEqual(['x', 'y']) + }) + + it('batchTrigger runs every item and does not reject when one throws (fire-and-forget)', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + if (p === 'x') throw new Error('bad x') + }, + }) + await expect(t.batchTrigger([{ payload: 'x' }, { payload: 'y' }])).resolves.toEqual({ id: 't' }) + expect(seen).toEqual(['x', 'y']) + }) + + it('trigger swallows a run failure and does not reject (fire-and-forget)', async () => { + const t = task({ + id: 't', + run: () => { + throw new Error('nope') + }, + }) + await expect(t.trigger(undefined)).resolves.toEqual({ id: 't' }) + }) + + it('preserves delete-before-create ordering across sequential awaited calls', async () => { + const order: string[] = [] + const del = task({ + id: 'del', + run: () => { + order.push('delete') + }, + }) + const create = task({ + id: 'create', + run: () => { + order.push('create') + }, + }) + // mirrors handleChannelFileChanges: delete batch awaited before create batch + await del.batchTriggerAndWait([{ payload: 1 }]) + await create.batchTriggerAndWait([{ payload: 2 }]) + expect(order).toEqual(['delete', 'create']) + }) + + it('schedules.task builds an inline handle too', async () => { + const run = vi.fn(() => Promise.resolve()) + const t = schedules.task({ id: 's', cron: '0 8 * * *', run }) + await t.trigger(undefined) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('logger methods are no-ops', () => { + expect(() => { + logger.info('x') + logger.error('y', {}) + logger.warn('z') + }).not.toThrow() + }) + + it('ApiError is an Error subclass carrying status', () => { + const e = new ApiError('boom') + e.status = 429 + expect(e).toBeInstanceOf(Error) + expect(e.status).toBe(429) + expect(e.message).toBe('boom') + }) +}) diff --git a/test/trigger/inlineSdk.ts b/test/trigger/inlineSdk.ts new file mode 100644 index 0000000..fdfb569 --- /dev/null +++ b/test/trigger/inlineSdk.ts @@ -0,0 +1,83 @@ +// Test-only double of @trigger.dev/sdk (and /v3): runs each task's own run() inline so +// integration tests drive the real task graph with no Trigger server. Matches the real +// error contract — *AndWait resolve a TaskRunResult (never reject), trigger/batchTrigger +// are fire-and-forget, batches always run every item. Concurrency is not modelled (L2). + +interface TaskConfig
{
+ id: string
+ run: (payload: P) => unknown | Promise {
+ trigger(payload: P, options?: unknown): Promise<{ id: string }>
+ triggerAndWait(payload: P, options?: unknown): Promise (config: TaskConfig ): InlineTaskHandle {
+ const { id, run } = config
+
+ // Run inline; capture success/throw as a result rather than rejecting.
+ const runToResult = async (payload: P): Promise