/**
* Schema validation tests for MCP tool parameters
* Tests edge cases and validation logic
*/
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
describe('Schema Validation', () => {
describe('Volume Schema', () => {
const volumeSchema = z.object({
volume_percent: z.number().min(0).max(100),
device_id: z.string().optional(),
});
it('should accept valid volume levels', () => {
expect(() => volumeSchema.parse({ volume_percent: 0 })).not.toThrow();
expect(() => volumeSchema.parse({ volume_percent: 1 })).not.toThrow();
expect(() => volumeSchema.parse({ volume_percent: 50 })).not.toThrow();
expect(() => volumeSchema.parse({ volume_percent: 99 })).not.toThrow();
expect(() => volumeSchema.parse({ volume_percent: 100 })).not.toThrow();
});
it('should reject invalid volume levels', () => {
expect(() => volumeSchema.parse({ volume_percent: -1 })).toThrow();
expect(() => volumeSchema.parse({ volume_percent: 101 })).toThrow();
expect(() => volumeSchema.parse({ volume_percent: 1000 })).toThrow();
});
it('should accept optional device_id', () => {
expect(() => volumeSchema.parse({ volume_percent: 50 })).not.toThrow();
expect(() => volumeSchema.parse({ volume_percent: 50, device_id: 'abc123' })).not.toThrow();
});
});
describe('Repeat Mode Schema', () => {
const repeatSchema = z.object({
state: z.enum(['track', 'context', 'off']),
device_id: z.string().optional(),
});
it('should accept valid repeat states', () => {
expect(() => repeatSchema.parse({ state: 'track' })).not.toThrow();
expect(() => repeatSchema.parse({ state: 'context' })).not.toThrow();
expect(() => repeatSchema.parse({ state: 'off' })).not.toThrow();
});
it('should reject invalid repeat states', () => {
expect(() => repeatSchema.parse({ state: 'on' })).toThrow();
expect(() => repeatSchema.parse({ state: 'true' })).toThrow();
expect(() => repeatSchema.parse({ state: 'false' })).toThrow();
expect(() => repeatSchema.parse({ state: 'playlist' })).toThrow();
});
});
describe('Shuffle Schema', () => {
const shuffleSchema = z.object({
state: z.boolean(),
device_id: z.string().optional(),
});
it('should accept boolean values', () => {
expect(() => shuffleSchema.parse({ state: true })).not.toThrow();
expect(() => shuffleSchema.parse({ state: false })).not.toThrow();
});
it('should reject non-boolean values', () => {
expect(() => shuffleSchema.parse({ state: 'true' })).toThrow();
expect(() => shuffleSchema.parse({ state: 'false' })).toThrow();
expect(() => shuffleSchema.parse({ state: 1 })).toThrow();
expect(() => shuffleSchema.parse({ state: 0 })).toThrow();
});
});
describe('Search Schema', () => {
const searchSchema = z.object({
query: z.string(),
type: z.array(z.enum(['track', 'album', 'artist', 'playlist'])),
limit: z.number().optional(),
});
it('should accept valid search queries', () => {
expect(() => searchSchema.parse({ query: 'test', type: ['track'] })).not.toThrow();
expect(() => searchSchema.parse({ query: 'The Beatles', type: ['artist', 'album'] })).not.toThrow();
expect(() => searchSchema.parse({ query: 'Rock', type: ['track', 'album', 'artist', 'playlist'] })).not.toThrow();
});
it('should accept optional limit', () => {
expect(() => searchSchema.parse({ query: 'test', type: ['track'] })).not.toThrow();
expect(() => searchSchema.parse({ query: 'test', type: ['track'], limit: 10 })).not.toThrow();
expect(() => searchSchema.parse({ query: 'test', type: ['track'], limit: 50 })).not.toThrow();
});
it('should accept empty type array (Zod default behavior)', () => {
// Note: Zod allows empty arrays by default unless .min(1) is used
// The actual implementation may validate this at runtime
expect(() => searchSchema.parse({ query: 'test', type: [] })).not.toThrow();
});
it('should reject invalid types', () => {
expect(() => searchSchema.parse({ query: 'test', type: ['song'] })).toThrow();
expect(() => searchSchema.parse({ query: 'test', type: ['video'] })).toThrow();
});
});
describe('Play Schema', () => {
const playSchema = z.object({
device_id: z.string().optional(),
context_uri: z.string().optional(),
uris: z.array(z.string()).optional(),
position_ms: z.number().optional(),
});
it('should accept empty parameters (resume playback)', () => {
expect(() => playSchema.parse({})).not.toThrow();
});
it('should accept context_uri', () => {
expect(() => playSchema.parse({ context_uri: 'spotify:album:123' })).not.toThrow();
expect(() => playSchema.parse({ context_uri: 'spotify:playlist:456' })).not.toThrow();
});
it('should accept uris array', () => {
expect(() => playSchema.parse({ uris: ['spotify:track:123'] })).not.toThrow();
expect(() => playSchema.parse({ uris: ['spotify:track:123', 'spotify:track:456'] })).not.toThrow();
});
it('should accept position_ms', () => {
expect(() => playSchema.parse({ position_ms: 0 })).not.toThrow();
expect(() => playSchema.parse({ position_ms: 30000 })).not.toThrow();
expect(() => playSchema.parse({ position_ms: 180000 })).not.toThrow();
});
it('should accept combined parameters', () => {
expect(() => playSchema.parse({
device_id: 'device123',
context_uri: 'spotify:album:789',
position_ms: 60000
})).not.toThrow();
});
});
describe('Seek Schema', () => {
const seekSchema = z.object({
position_ms: z.number(),
device_id: z.string().optional(),
});
it('should require position_ms', () => {
expect(() => seekSchema.parse({})).toThrow();
expect(() => seekSchema.parse({ position_ms: 30000 })).not.toThrow();
});
it('should accept any positive position', () => {
expect(() => seekSchema.parse({ position_ms: 0 })).not.toThrow();
expect(() => seekSchema.parse({ position_ms: 1 })).not.toThrow();
expect(() => seekSchema.parse({ position_ms: 999999 })).not.toThrow();
});
});
describe('Pagination Schema', () => {
const paginationSchema = z.object({
limit: z.number().optional(),
offset: z.number().optional(),
});
it('should accept pagination parameters', () => {
expect(() => paginationSchema.parse({})).not.toThrow();
expect(() => paginationSchema.parse({ limit: 20 })).not.toThrow();
expect(() => paginationSchema.parse({ offset: 0 })).not.toThrow();
expect(() => paginationSchema.parse({ limit: 50, offset: 100 })).not.toThrow();
});
it('should accept common pagination values', () => {
expect(() => paginationSchema.parse({ limit: 10 })).not.toThrow();
expect(() => paginationSchema.parse({ limit: 20 })).not.toThrow();
expect(() => paginationSchema.parse({ limit: 50 })).not.toThrow();
});
});
describe('ID Parameter Schemas', () => {
const trackIdSchema = z.object({ track_id: z.string() });
const albumIdSchema = z.object({ album_id: z.string() });
const artistIdSchema = z.object({ artist_id: z.string() });
const playlistIdSchema = z.object({ playlist_id: z.string() });
it('should require ID parameters', () => {
expect(() => trackIdSchema.parse({})).toThrow();
expect(() => albumIdSchema.parse({})).toThrow();
expect(() => artistIdSchema.parse({})).toThrow();
expect(() => playlistIdSchema.parse({})).toThrow();
});
it('should accept valid Spotify IDs', () => {
expect(() => trackIdSchema.parse({ track_id: '3n3Ppam7vgaVa1iaRUc9Lp' })).not.toThrow();
expect(() => albumIdSchema.parse({ album_id: '4piJq7R3gjUOxnYs6lDCTg' })).not.toThrow();
expect(() => artistIdSchema.parse({ artist_id: '0C0XlULifJtAgn6ZNCW2eu' })).not.toThrow();
expect(() => playlistIdSchema.parse({ playlist_id: '37i9dQZF1DXcBWIGoYBM5M' })).not.toThrow();
});
it('should accept any string as ID', () => {
expect(() => trackIdSchema.parse({ track_id: 'abc' })).not.toThrow();
expect(() => trackIdSchema.parse({ track_id: '123' })).not.toThrow();
expect(() => trackIdSchema.parse({ track_id: 'test-id-123' })).not.toThrow();
});
});
describe('URI Parameter Schemas', () => {
const uriSchema = z.object({ uri: z.string(), device_id: z.string().optional() });
it('should accept Spotify URIs', () => {
expect(() => uriSchema.parse({ uri: 'spotify:track:3n3Ppam7vgaVa1iaRUc9Lp' })).not.toThrow();
expect(() => uriSchema.parse({ uri: 'spotify:album:4piJq7R3gjUOxnYs6lDCTg' })).not.toThrow();
expect(() => uriSchema.parse({ uri: 'spotify:artist:0C0XlULifJtAgn6ZNCW2eu' })).not.toThrow();
expect(() => uriSchema.parse({ uri: 'spotify:playlist:37i9dQZF1DXcBWIGoYBM5M' })).not.toThrow();
});
});
describe('Market Parameter Schema', () => {
const marketSchema = z.object({
market: z.string().optional(),
});
it('should accept market codes', () => {
expect(() => marketSchema.parse({})).not.toThrow();
expect(() => marketSchema.parse({ market: 'US' })).not.toThrow();
expect(() => marketSchema.parse({ market: 'GB' })).not.toThrow();
expect(() => marketSchema.parse({ market: 'JP' })).not.toThrow();
});
});
describe('Transfer Playback Schema', () => {
const transferSchema = z.object({
device_id: z.string(),
play: z.boolean().optional(),
});
it('should require device_id', () => {
expect(() => transferSchema.parse({})).toThrow();
expect(() => transferSchema.parse({ device_id: 'device123' })).not.toThrow();
});
it('should accept optional play parameter', () => {
expect(() => transferSchema.parse({ device_id: 'device123' })).not.toThrow();
expect(() => transferSchema.parse({ device_id: 'device123', play: true })).not.toThrow();
expect(() => transferSchema.parse({ device_id: 'device123', play: false })).not.toThrow();
});
});
describe('Empty Schema Tools', () => {
const emptySchema = z.object({});
it('should accept empty parameters', () => {
expect(() => emptySchema.parse({})).not.toThrow();
});
it('should work for all granular getter tools', () => {
// All granular track info, device info, and state tools use empty schema
const granularTools = [
'spotify_get_track_name',
'spotify_get_artist_name',
'spotify_get_album_name',
'spotify_is_playing',
'spotify_get_device_name',
'spotify_get_shuffle_state',
];
granularTools.forEach(() => {
expect(() => emptySchema.parse({})).not.toThrow();
});
});
});
});