diff --git a/test/commands/file/upload.test.ts b/test/commands/file/upload.test.ts index c48994d..286b0e2 100644 --- a/test/commands/file/upload.test.ts +++ b/test/commands/file/upload.test.ts @@ -1,38 +1,61 @@ import { describe, it, expect } from 'bun:test'; import { default as uploadCommand } from '../../../src/commands/file/upload'; +const baseConfig = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'text' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, +}; + +const baseFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + describe('file upload command', () => { it('has correct name', () => { expect(uploadCommand.name).toBe('file upload'); }); - it('requires file argument', async () => { - const config = { - apiKey: 'test-key', - region: 'global' as const, - baseUrl: 'https://api.mmx.io', - output: 'text' as const, - timeout: 10, - verbose: false, - quiet: false, - noColor: true, - yes: false, - dryRun: true, - nonInteractive: true, - async: false, - }; - + it('requires file argument in non-interactive mode', async () => { await expect( - uploadCommand.execute(config, { - quiet: false, - verbose: false, - noColor: true, - yes: false, - dryRun: true, - help: false, - nonInteractive: true, - async: false, - }), + uploadCommand.execute(baseConfig, baseFlags), ).rejects.toThrow('Missing required argument: --file'); }); + + it('throws when file does not exist', async () => { + await expect( + uploadCommand.execute(baseConfig, { ...baseFlags, file: '/tmp/nonexistent-file-xxxxx.bin' }), + ).rejects.toThrow('File not found'); + }); + + it('shows dry-run output with file info', async () => { + let captured = ''; + const origWrite = process.stdout.write; + process.stdout.write = (chunk: any): any => { captured += String(chunk); return true; }; + + await uploadCommand.execute( + { ...baseConfig, dryRun: true }, + { ...baseFlags, dryRun: true, file: '/dev/null', purpose: 'vision' }, + ); + + process.stdout.write = origWrite; + expect(captured).toContain('/dev/null'); + expect(captured).toContain('vision'); + }); }); \ No newline at end of file diff --git a/test/commands/image/generate.test.ts b/test/commands/image/generate.test.ts index fb29b6a..1cdd56a 100644 --- a/test/commands/image/generate.test.ts +++ b/test/commands/image/generate.test.ts @@ -1,38 +1,94 @@ import { describe, it, expect } from 'bun:test'; import { default as generateCommand } from '../../../src/commands/image/generate'; +const baseConfig = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'text' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, +}; + +const baseFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + describe('image generate command', () => { it('has correct name', () => { expect(generateCommand.name).toBe('image generate'); }); it('requires prompt', async () => { - const config = { - apiKey: 'test-key', - region: 'global' as const, - baseUrl: 'https://api.mmx.io', - output: 'text' as const, - timeout: 10, - verbose: false, - quiet: false, - noColor: true, - yes: false, - dryRun: false, - nonInteractive: true, - async: false, - }; - await expect( - generateCommand.execute(config, { - quiet: false, - verbose: false, - noColor: true, - yes: false, - dryRun: false, - help: false, - nonInteractive: true, - async: false, - }), + generateCommand.execute(baseConfig, baseFlags), ).rejects.toThrow('Missing required argument: --prompt'); }); + + it('throws when width is provided without height', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'test', width: 1024 }), + ).rejects.toThrow('--width requires --height'); + }); + + it('throws when height is provided without width', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'test', height: 1024 }), + ).rejects.toThrow('--height requires --width'); + }); + + it('throws when width is below 512', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'test', width: 256, height: 256 }), + ).rejects.toThrow('must be between 512 and 2048'); + }); + + it('throws when height is above 2048', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'test', width: 1024, height: 4096 }), + ).rejects.toThrow('must be between 512 and 2048'); + }); + + it('throws when dimensions are not multiples of 8', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'test', width: 1025, height: 1024 }), + ).rejects.toThrow('must be a multiple of 8'); + }); + + it('throws when --out is used with --n > 1', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'test', out: '/tmp/img.jpg', n: 3 }), + ).rejects.toThrow('--out cannot be used with --n > 1'); + }); + + it('builds correct request body in dry-run', async () => { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + try { + await generateCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const }, + { ...baseFlags, dryRun: true, prompt: 'A cat', aspectRatio: '16:9', n: 2, seed: 42 }, + ); + } catch { /* dry-run may log or resolve */ } + console.log = origLog; + const parsed = JSON.parse(captured); + expect(parsed.request.prompt).toBe('A cat'); + expect(parsed.request.n).toBe(2); + expect(parsed.request.seed).toBe(42); + expect(parsed.request.model).toBe('image-01'); + }); }); diff --git a/test/commands/quota/show.test.ts b/test/commands/quota/show.test.ts index ebb88fd..3d145c1 100644 --- a/test/commands/quota/show.test.ts +++ b/test/commands/quota/show.test.ts @@ -1,46 +1,50 @@ import { describe, it, expect } from 'bun:test'; import { default as showCommand } from '../../../src/commands/quota/show'; +const baseConfig = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'text' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, +}; + +const baseFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + describe('quota show command', () => { it('has correct name', () => { expect(showCommand.name).toBe('quota show'); }); it('handles dry run', async () => { - const config = { - apiKey: 'test-key', - region: 'global' as const, - baseUrl: 'https://api.mmx.io', - output: 'text' as const, - timeout: 10, - verbose: false, - quiet: false, - noColor: true, - yes: false, - dryRun: true, - nonInteractive: true, - async: false, - }; - - const originalLog = console.log; - let output = ''; - console.log = (msg: string) => { output += msg; }; - + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; try { - await showCommand.execute(config, { - quiet: false, - verbose: false, - noColor: true, - yes: false, - dryRun: true, - help: false, - nonInteractive: true, - async: false, - }); - - expect(output).toContain('Would fetch quota'); + await showCommand.execute( + { ...baseConfig, dryRun: true }, + { ...baseFlags, dryRun: true }, + ); + expect(captured).toContain('Would fetch quota'); } finally { - console.log = originalLog; + console.log = origLog; } }); + }); diff --git a/test/commands/search/query.test.ts b/test/commands/search/query.test.ts index 6784be1..f8871c0 100644 --- a/test/commands/search/query.test.ts +++ b/test/commands/search/query.test.ts @@ -1,38 +1,96 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, afterEach } from 'bun:test'; +import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server'; import { default as queryCommand } from '../../../src/commands/search/query'; +const baseConfig = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'text' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, +}; + +const baseFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + describe('search query command', () => { it('has correct name', () => { expect(queryCommand.name).toBe('search query'); }); it('requires q argument', async () => { - const config = { - apiKey: 'test-key', - region: 'global' as const, - baseUrl: 'https://api.mmx.io', - output: 'text' as const, - timeout: 10, - verbose: false, - quiet: false, - noColor: true, - yes: false, - dryRun: true, - nonInteractive: true, - async: false, - }; - await expect( - queryCommand.execute(config, { - quiet: false, - verbose: false, - noColor: true, - yes: false, - dryRun: true, - help: false, - nonInteractive: true, - async: false, - }), + queryCommand.execute({ ...baseConfig, dryRun: true }, { ...baseFlags, dryRun: true }), ).rejects.toThrow('--q is required'); }); +}); + +describe('search query command with mock server', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('searches and displays results', async () => { + server = createMockServer({ + routes: { + '/v1/coding_plan/search': () => jsonResponse({ + organic: [ + { title: 'Result 1', link: 'https://example.com/1', snippet: 'Snippet one', date: '2026-01-01' }, + { title: 'Result 2', link: 'https://example.com/2', snippet: 'Snippet two', date: '2026-01-02' }, + ], + }), + }, + }); + + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg + '\n'; }; + + await queryCommand.execute( + { ...baseConfig, baseUrl: server.url }, + { ...baseFlags, q: 'test query' }, + ); + + console.log = origLog; + expect(captured).toContain('Result 1'); + expect(captured).toContain('https://example.com/1'); + expect(captured).toContain('Result 2'); + }); + + it('handles empty results', async () => { + server = createMockServer({ + routes: { + '/v1/coding_plan/search': () => jsonResponse({ organic: [] }), + }, + }); + + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg + '\n'; }; + + await queryCommand.execute( + { ...baseConfig, baseUrl: server.url }, + { ...baseFlags, q: 'no results' }, + ); + + console.log = origLog; + expect(captured).toContain('No results found'); + }); }); \ No newline at end of file diff --git a/test/config/schema.test.ts b/test/config/schema.test.ts new file mode 100644 index 0000000..b4bd6ee --- /dev/null +++ b/test/config/schema.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'bun:test'; +import { parseConfigFile } from '../../src/config/schema'; + +describe('parseConfigFile', () => { + it('returns empty object for null/undefined', () => { + expect(parseConfigFile(null)).toEqual({}); + expect(parseConfigFile(undefined)).toEqual({}); + }); + + it('returns empty object for non-objects', () => { + expect(parseConfigFile('string')).toEqual({}); + expect(parseConfigFile(42)).toEqual({}); + expect(parseConfigFile([])).toEqual({}); + }); + + it('parses valid api_key', () => { + expect(parseConfigFile({ api_key: 'sk-cp-test' })).toEqual({ api_key: 'sk-cp-test' }); + }); + + it('rejects non-string api_key', () => { + expect(parseConfigFile({ api_key: 123 })).toEqual({}); + }); + + it('accepts valid region values only', () => { + expect(parseConfigFile({ region: 'global' }).region).toBe('global'); + expect(parseConfigFile({ region: 'cn' }).region).toBe('cn'); + expect(parseConfigFile({ region: 'us' }).region).toBeUndefined(); + expect(parseConfigFile({ region: '' }).region).toBeUndefined(); + }); + + it('accepts base_url only when starts with http', () => { + expect(parseConfigFile({ base_url: 'https://custom.api.com' }).base_url).toBe('https://custom.api.com'); + expect(parseConfigFile({ base_url: 'http://localhost:8080' }).base_url).toBe('http://localhost:8080'); + expect(parseConfigFile({ base_url: 'not-a-url' }).base_url).toBeUndefined(); + expect(parseConfigFile({ base_url: 123 }).base_url).toBeUndefined(); + }); + + it('accepts valid output values only', () => { + expect(parseConfigFile({ output: 'text' }).output).toBe('text'); + expect(parseConfigFile({ output: 'json' }).output).toBe('json'); + expect(parseConfigFile({ output: 'xml' }).output).toBeUndefined(); + }); + + it('accepts positive timeout', () => { + expect(parseConfigFile({ timeout: 300 }).timeout).toBe(300); + expect(parseConfigFile({ timeout: 0 }).timeout).toBeUndefined(); + expect(parseConfigFile({ timeout: -1 }).timeout).toBeUndefined(); + expect(parseConfigFile({ timeout: '300' }).timeout).toBeUndefined(); + }); + + it('accepts proxy only when starts with http', () => { + expect(parseConfigFile({ proxy: 'http://proxy:8080' }).proxy).toBe('http://proxy:8080'); + expect(parseConfigFile({ proxy: 'socks5://proxy' }).proxy).toBeUndefined(); + }); + + it('parses valid OAuth credentials', () => { + const cfg = parseConfigFile({ + oauth: { + access_token: 'at-123', + refresh_token: 'rt-456', + expires_at: '2026-01-01T00:00:00Z', + region: 'global', + resource_url: 'https://api.example.com', + account: 'user@test.com', + }, + }); + expect(cfg.oauth).toBeDefined(); + expect(cfg.oauth!.access_token).toBe('at-123'); + expect(cfg.oauth!.refresh_token).toBe('rt-456'); + expect(cfg.oauth!.expires_at).toBe('2026-01-01T00:00:00Z'); + expect(cfg.oauth!.region).toBe('global'); + expect(cfg.oauth!.resource_url).toBe('https://api.example.com'); + expect(cfg.oauth!.account).toBe('user@test.com'); + }); + + it('rejects OAuth missing access_token', () => { + expect(parseConfigFile({ oauth: { refresh_token: 'rt', expires_at: '...' } }).oauth).toBeUndefined(); + }); + + it('rejects OAuth missing refresh_token', () => { + expect(parseConfigFile({ oauth: { access_token: 'at', expires_at: '...' } }).oauth).toBeUndefined(); + }); + + it('rejects OAuth missing expires_at', () => { + expect(parseConfigFile({ oauth: { access_token: 'at', refresh_token: 'rt' } }).oauth).toBeUndefined(); + }); + + it('parses default model settings', () => { + const cfg = parseConfigFile({ + default_text_model: 'MiniMax-M2.7', + default_speech_model: 'speech-2.8-hd', + default_video_model: 'MiniMax-Hailuo-2.3', + default_music_model: 'music-2.6', + }); + expect(cfg.default_text_model).toBe('MiniMax-M2.7'); + expect(cfg.default_speech_model).toBe('speech-2.8-hd'); + expect(cfg.default_video_model).toBe('MiniMax-Hailuo-2.3'); + expect(cfg.default_music_model).toBe('music-2.6'); + }); + + it('rejects empty string default model', () => { + expect(parseConfigFile({ default_text_model: '' }).default_text_model).toBeUndefined(); + }); + + it('handles full valid config', () => { + const cfg = parseConfigFile({ + api_key: 'sk-cp-test', + region: 'cn', + base_url: 'https://api.minimaxi.com', + output: 'json', + timeout: 120, + proxy: 'http://proxy:3128', + default_text_model: 'MiniMax-M2.7', + }); + expect(cfg.api_key).toBe('sk-cp-test'); + expect(cfg.region).toBe('cn'); + expect(cfg.base_url).toBe('https://api.minimaxi.com'); + expect(cfg.output).toBe('json'); + expect(cfg.timeout).toBe(120); + expect(cfg.proxy).toBe('http://proxy:3128'); + expect(cfg.default_text_model).toBe('MiniMax-M2.7'); + }); + + it('silently ignores unknown keys', () => { + const cfg = parseConfigFile({ unknown_key: 'value', api_key: 'sk-test' }); + expect(cfg.api_key).toBe('sk-test'); + expect((cfg as Record).unknown_key).toBeUndefined(); + }); +}); diff --git a/test/errors/base.test.ts b/test/errors/base.test.ts new file mode 100644 index 0000000..0bafb01 --- /dev/null +++ b/test/errors/base.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'bun:test'; +import { CLIError, SDKError } from '../../src/errors/base'; +import { ExitCode } from '../../src/errors/codes'; + +describe('CLIError', () => { + it('sets name, message, and exitCode', () => { + const err = new CLIError('something went wrong', ExitCode.GENERAL); + expect(err.name).toBe('CLIError'); + expect(err.message).toBe('something went wrong'); + expect(err.exitCode).toBe(ExitCode.GENERAL); + }); + + it('defaults to ExitCode.GENERAL', () => { + const err = new CLIError('oops'); + expect(err.exitCode).toBe(1); + expect(err.hint).toBeUndefined(); + }); + + it('accepts optional hint', () => { + const err = new CLIError('auth failed', ExitCode.AUTH, 'Try mmx auth login'); + expect(err.exitCode).toBe(3); + expect(err.hint).toBe('Try mmx auth login'); + }); + + it('toJSON includes code and message', () => { + const err = new CLIError('bad input', ExitCode.USAGE); + const json = err.toJSON(); + expect(json.error.code).toBe(2); + expect(json.error.message).toBe('bad input'); + expect(json.error.hint).toBeUndefined(); + }); + + it('toJSON includes hint when present', () => { + const err = new CLIError('quota', ExitCode.QUOTA, 'Upgrade your plan'); + const json = err.toJSON(); + expect(json.error.code).toBe(4); + expect(json.error.hint).toBe('Upgrade your plan'); + }); +}); + +describe('SDKError', () => { + it('sets name to SDKError', () => { + const err = new SDKError('sdk failure', ExitCode.USAGE); + expect(err.name).toBe('SDKError'); + expect(err).toBeInstanceOf(CLIError); + }); + + it('inherits toJSON from CLIError', () => { + const err = new SDKError('validation', ExitCode.USAGE, 'Check params'); + const json = err.toJSON(); + expect(json.error.code).toBe(2); + expect(json.error.message).toBe('validation'); + expect(json.error.hint).toBe('Check params'); + }); +}); diff --git a/test/polling/poll.test.ts b/test/polling/poll.test.ts new file mode 100644 index 0000000..b0c80ea --- /dev/null +++ b/test/polling/poll.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, mock, afterEach } from 'bun:test'; +import type { Config } from '../../src/config/schema'; + +// Silence the spinner — purely visual, no impact on logic. +// Must re-export all symbols so other test files don't break. +mock.module('../../src/output/progress', () => ({ + createSpinner: () => ({ start: () => {}, stop: () => {}, update: () => {} }), + createProgressBar: () => ({ update: () => {}, finish: () => {} }), +})); + +const baseConfig: Config = { + apiKey: 'sk-test', + region: 'global', + baseUrl: 'https://api.mmx.io', + output: 'text', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, +}; + +function jsonRes(body: unknown): Response { + return { + status: 200, + ok: true, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + } as Response; +} + +const originalFetch = globalThis.fetch; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function setFetch(fn: any): void { + globalThis.fetch = fn as typeof globalThis.fetch; +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('poll', () => { + it('completes when isComplete returns true', async () => { + let callCount = 0; + setFetch(mock(() => { + callCount++; + return Promise.resolve(jsonRes({ status: 'Success', task_id: 'task-1' })); + })); + + const { poll } = await import('../../src/polling/poll'); + const result = await poll(baseConfig, { + url: 'https://api.mmx.io/poll', + intervalSec: 0.01, + timeoutSec: 5, + isComplete: (d) => (d as Record).status === 'Success', + isFailed: () => false, + }); + + expect((result as Record).status).toBe('Success'); + expect(callCount).toBeGreaterThanOrEqual(1); + }); + + it('throws on isFailed with status message', async () => { + setFetch(mock(() => Promise.resolve( + jsonRes({ status: 'Failed', base_resp: { status_code: 1, status_msg: 'Task error' } }), + ))); + + const { poll } = await import('../../src/polling/poll'); + await expect( + poll(baseConfig, { + url: 'https://api.mmx.io/poll', + intervalSec: 0.01, + timeoutSec: 5, + isComplete: () => false, + isFailed: (d) => (d as Record).status === 'Failed', + getStatus: (d) => (d as Record).status as string, + }), + ).rejects.toThrow('Task error'); + }); + + it('throws on timeout', async () => { + setFetch(mock(() => Promise.resolve(jsonRes({ status: 'Processing' })))); + + const { poll } = await import('../../src/polling/poll'); + await expect( + poll(baseConfig, { + url: 'https://api.mmx.io/poll', + intervalSec: 0.01, + timeoutSec: 0.02, + isComplete: () => false, + isFailed: () => false, + }), + ).rejects.toThrow('Polling timed out'); + }); + + it('returns immediately when first request succeeds', async () => { + let callCount = 0; + setFetch(mock(() => { + callCount++; + return Promise.resolve(jsonRes({ status: 'Success' })); + })); + + const { poll } = await import('../../src/polling/poll'); + const result = await poll(baseConfig, { + url: 'https://api.mmx.io/poll', + intervalSec: 0.01, + timeoutSec: 5, + isComplete: () => true, + isFailed: () => false, + }); + + expect(callCount).toBe(1); + expect(result).toBeDefined(); + }); +}); diff --git a/test/sdk/image.test.ts b/test/sdk/image.test.ts index 3920ef0..e07edfd 100644 --- a/test/sdk/image.test.ts +++ b/test/sdk/image.test.ts @@ -121,3 +121,33 @@ describe('ImageSDK.save', () => { unlinkSync(saved[0]!); }); }); + +describe('ImageSDK.validateParams', () => { + const sdk = new ImageSDK({ apiKey: 'sk-test', region: 'global' }); + + it('throws when width is provided without height', async () => { + await expect(sdk.generate({ prompt: 'test', width: 1024 })).rejects.toThrow('Both width and height must be provided'); + }); + + it('throws when height is provided without width', async () => { + await expect(sdk.generate({ prompt: 'test', height: 1024 })).rejects.toThrow('Both width and height must be provided'); + }); + + it('throws when width is below 512', async () => { + await expect(sdk.generate({ prompt: 'test', width: 256, height: 256 })).rejects.toThrow('must be between 512 and 2048'); + }); + + it('throws when height is above 2048', async () => { + await expect(sdk.generate({ prompt: 'test', width: 1024, height: 4096 })).rejects.toThrow('must be between 512 and 2048'); + }); + + it('throws when dimensions are not multiples of 8', async () => { + await expect(sdk.generate({ prompt: 'test', width: 1025, height: 1024 })).rejects.toThrow('must be a multiple of 8'); + }); + + it('accepts valid dimensions (passes validation, fails on network)', async () => { + await expect( + sdk.generate({ prompt: 'test', width: 1024, height: 1024 }), + ).rejects.not.toThrow(/width|height/); + }); +}); diff --git a/test/sdk/music.test.ts b/test/sdk/music.test.ts index 8a72b40..87a9505 100644 --- a/test/sdk/music.test.ts +++ b/test/sdk/music.test.ts @@ -84,3 +84,55 @@ describe('MusicSDK.save', () => { expect(() => sdk.save(response, '/tmp/test.mp3')).toThrow('missing audio data'); }); }); + +describe('MusicSDK.validateParams', () => { + const sdk = new MusicSDK({ apiKey: 'sk-test', region: 'global' }); + + it('throws when is_instrumental and lyrics are both provided', async () => { + await expect( + sdk.generate({ is_instrumental: true, lyrics: 'hello' } as any), + ).rejects.toThrow('Cannot use is_instrumental with lyrics'); + }); + + it('throws when lyrics_optimizer is used with lyrics', async () => { + await expect( + sdk.generate({ lyrics_optimizer: true, lyrics: 'hello' } as any), + ).rejects.toThrow('Cannot use lyrics_optimizer with lyrics or is_instrumental'); + }); + + it('throws when lyrics_optimizer is used with is_instrumental', async () => { + await expect( + sdk.generate({ lyrics_optimizer: true, is_instrumental: true } as any), + ).rejects.toThrow('Cannot use lyrics_optimizer with lyrics or is_instrumental'); + }); + + it('throws when no prompt, lyrics, is_instrumental, or lyrics_optimizer', async () => { + await expect( + sdk.generate({} as any), + ).rejects.toThrow('At least one of prompt or lyrics or is_instrumental or lyrics_optimizer is required'); + }); + + it('throws when lyrics is missing (not is_instrumental, not lyrics_optimizer)', async () => { + await expect( + sdk.generate({ prompt: 'Upbeat pop' } as any), + ).rejects.toThrow('lyrics is required'); + }); + + it('throws on invalid model', async () => { + await expect( + sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', model: 'music-2.0' } as any), + ).rejects.toThrow('Invalid model'); + }); + + it('throws on invalid output_format', async () => { + await expect( + sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', output_format: 'mp3' } as any), + ).rejects.toThrow('Invalid output format'); + }); + + it('throws when stream and output_format=url are used together', async () => { + await expect( + sdk.generate({ prompt: 'Folk', lyrics: 'no lyrics', stream: true, output_format: 'url' } as any), + ).rejects.toThrow('stream and output_format url cannot be used together'); + }); +}); diff --git a/test/sdk/speech.test.ts b/test/sdk/speech.test.ts index 772ae7e..7f8a886 100644 --- a/test/sdk/speech.test.ts +++ b/test/sdk/speech.test.ts @@ -106,3 +106,15 @@ describe('SpeechSDK.save', () => { expect(() => sdk.save(response, '/tmp/test.mp3')).toThrow('missing audio data'); }); }); + +describe('SpeechSDK.validateParams', () => { + const sdk = new SpeechSDK({ apiKey: 'sk-test', region: 'global' }); + + it('throws when text is missing', async () => { + await expect(sdk.synthesize({} as any)).rejects.toThrow('text is required'); + }); + + it('throws when text is empty string', async () => { + await expect(sdk.synthesize({ text: '' })).rejects.toThrow('text is required'); + }); +}); diff --git a/test/sdk/text.test.ts b/test/sdk/text.test.ts index 1ed759b..0c5e40c 100644 --- a/test/sdk/text.test.ts +++ b/test/sdk/text.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { createMockServer, jsonResponse, sseResponse, type MockServer } from '../helpers/mock-server'; import { MiniMaxSDK } from '../../src/sdk'; +import { TextSDK } from '../../src/sdk/text'; describe('MiniMaxSDK.text', () => { let server: MockServer; @@ -73,3 +74,17 @@ describe('MiniMaxSDK.text', () => { expect(events[1].type).toBe('content_block_delta'); }); }); + +describe('TextSDK.validateParams', () => { + const sdk = new TextSDK({ apiKey: 'sk-test', region: 'global' }); + + it('throws when messages array is empty', async () => { + await expect(sdk.chat({ messages: [] })).rejects.toThrow('At least one message is required'); + }); + + it('applies defaults for model and max_tokens', async () => { + await expect( + sdk.chat({ messages: [{ role: 'user', content: 'Hi' }] }), + ).rejects.not.toThrow('At least one message'); + }); +}); diff --git a/test/sdk/video.test.ts b/test/sdk/video.test.ts index 58e4aa0..c311d4e 100644 --- a/test/sdk/video.test.ts +++ b/test/sdk/video.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server'; import { MiniMaxSDK } from '../../src/sdk'; +import { VideoSDK } from '../../src/sdk/video'; describe('MiniMaxSDK.video', () => { let server: MockServer; @@ -53,3 +54,54 @@ describe('MiniMaxSDK.video', () => { expect(result.status).toBe('Success'); }); }); + +describe('VideoSDK.validateParams', () => { + const sdk = new VideoSDK({ apiKey: 'sk-test', region: 'global' }); + + it('throws when prompt is missing', async () => { + await expect(sdk.generate({} as any)).rejects.toThrow('prompt is required'); + }); + + it('throws when last_frame_image is provided without first_frame_image', async () => { + await expect( + sdk.generate({ prompt: 'test', last_frame_image: 'data:image/png;base64,xxx' }), + ).rejects.toThrow('last_frame_image requires first_frame_image'); + }); + + it('throws when last_frame_image and subject_reference are used together', async () => { + await expect( + sdk.generate({ + prompt: 'test', + first_frame_image: 'data:image/png;base64,xxx', + last_frame_image: 'data:image/png;base64,yyy', + subject_reference: [{ type: 'character', image: ['data:image/png;base64,zzz'] }], + }), + ).rejects.toThrow('SEF and S2V are different modes'); + }); + + it('throws when Fast model used without first_frame_image', async () => { + await expect( + sdk.generate({ prompt: 'test', model: 'MiniMax-Hailuo-2.3-Fast' }), + ).rejects.toThrow('MiniMax-Hailuo-2.3-Fast only supports I2V'); + }); + + it('auto-selects SEF model when last_frame_image is provided', async () => { + // Validation passes → tries network → fails with non-validation error + await expect( + sdk.generate({ + prompt: 'test', + first_frame_image: 'data:image/png;base64,xxx', + last_frame_image: 'data:image/png;base64,yyy', + }), + ).rejects.not.toThrow(/prompt|last_frame/); + }); + + it('auto-selects S2V model when subject_reference is provided', async () => { + await expect( + sdk.generate({ + prompt: 'test', + subject_reference: [{ type: 'character', image: ['data:image/png;base64,zzz'] }], + }), + ).rejects.not.toThrow(/prompt|subject/); + }); +}); diff --git a/test/utils/env.test.ts b/test/utils/env.test.ts new file mode 100644 index 0000000..6a92a15 --- /dev/null +++ b/test/utils/env.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { isInteractive, isCI } from '../../src/utils/env'; + +function withTTY(stdout: boolean, stdin: boolean, fn: () => void): void { + const origStdout = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + const origStdin = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { value: stdout, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: stdin, configurable: true }); + try { fn(); } finally { + if (origStdout) { + Object.defineProperty(process.stdout, 'isTTY', origStdout); + } else { + delete (process.stdout as unknown as Record).isTTY; + } + if (origStdin) { + Object.defineProperty(process.stdin, 'isTTY', origStdin); + } else { + delete (process.stdin as unknown as Record).isTTY; + } + } +} + +describe('isInteractive', () => { + // Ensure CI env is clean before each test — other test files may set it + beforeEach(() => { + delete process.env.CI; + }); + + it('returns true when both stdout and stdin are TTYs', () => { + withTTY(true, true, () => { + expect(isInteractive()).toBe(true); + }); + }); + + it('returns false when stdout is not a TTY', () => { + withTTY(false, true, () => { + expect(isInteractive()).toBe(false); + }); + }); + + it('returns false when stdin is not a TTY', () => { + withTTY(true, false, () => { + expect(isInteractive()).toBe(false); + }); + }); + + it('returns false when --non-interactive is set', () => { + withTTY(true, true, () => { + expect(isInteractive({ nonInteractive: true })).toBe(false); + }); + }); + + it('returns false in CI even with TTYs', () => { + process.env.CI = 'true'; + withTTY(true, true, () => { + expect(isInteractive()).toBe(false); + }); + }); +}); + +describe('isCI', () => { + beforeEach(() => { + delete process.env.CI; + delete process.env.GITHUB_ACTIONS; + delete process.env.GITLAB_CI; + delete process.env.JENKINS_URL; + delete process.env.TRAVIS; + delete process.env.CIRCLECI; + }); + + it('returns true when CI env var is set', () => { + process.env.CI = '1'; + expect(isCI()).toBe(true); + }); + + it('returns true for GitHub Actions', () => { + process.env.GITHUB_ACTIONS = 'true'; + expect(isCI()).toBe(true); + }); + + it('returns true for GitLab CI', () => { + process.env.GITLAB_CI = 'true'; + expect(isCI()).toBe(true); + }); + + it('returns true for Jenkins', () => { + process.env.JENKINS_URL = 'http://jenkins'; + expect(isCI()).toBe(true); + }); + + it('returns false when no CI env vars are set', () => { + expect(isCI()).toBe(false); + }); +}); diff --git a/test/utils/token.test.ts b/test/utils/token.test.ts new file mode 100644 index 0000000..ba6085d --- /dev/null +++ b/test/utils/token.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'bun:test'; +import { maskToken } from '../../src/utils/token'; + +describe('maskToken', () => { + it('masks a standard token (show first 4, last 4)', () => { + expect(maskToken('sk-cp-1234567890abcdef')).toBe('sk-c...cdef'); + }); + + it('masks a short token (show first 4, last 4)', () => { + expect(maskToken('sk-abcd1234')).toBe('sk-a...1234'); + }); + + it('uses *** for tokens <= 8 chars', () => { + expect(maskToken('sk-1234')).toBe('***'); + expect(maskToken('abcdefgh')).toBe('***'); + expect(maskToken('12345678')).toBe('***'); + }); + + it('handles 9-char token (first 4 + last 4 > total)', () => { + expect(maskToken('123456789')).toBe('1234...6789'); + }); + + it('handles empty string gracefully', () => { + expect(maskToken('')).toBe('***'); + }); +});