Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 188 additions & 1 deletion cli/src/__tests__/unit/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
/**
* Unit tests for validation utilities.
*
* Tests UUID validation, shortuuid validation, and session ID utilities
* including format detection, normalization, and display formatting.
*/

import { isValidUuid } from '../../utils/validation.js';
import {
detectSessionIdFormat,
formatSessionIdForDisplay,
isValidSessionId,
isValidShortUuid,
isValidUuid,
normalizeSessionId,
} from '../../utils/validation.js';

/** Characters excluded from base57 alphabet (similar-looking) */
const BASE57_EXCLUDED_CHARS = ['0', '1', 'I', 'O', 'l'] as const;

/** UUID 00000000-0000-0000-0000-000000000000 encodes to all 2s */
const ZERO_UUID_ENCODING = '2222222222222222222222';

describe('isValidUuid', () => {
describe('valid UUIDs', () => {
Expand Down Expand Up @@ -72,3 +88,174 @@ describe('isValidUuid', () => {
});
});
});

describe('isValidShortUuid', () => {
describe('valid shortuuids', () => {
it('should accept valid 22-character shortuuids', () => {
expect(isValidShortUuid('CXc85b4rqinB7s5J52TRYb')).toBe(true);
expect(isValidShortUuid('vytxeTZskVKR7C7WgdSP3d')).toBe(true);
});

it('should accept shortuuids with hyphens', () => {
expect(isValidShortUuid('CXc8-5b4r-qinB-7s5J-52TR-Yb')).toBe(true);
expect(isValidShortUuid('vy-txeT-ZskV-KR7C-7Wgd-SP3d')).toBe(true);
});

it('should accept zero UUID encoding', () => {
expect(isValidShortUuid(ZERO_UUID_ENCODING)).toBe(true);
});
});

describe('invalid shortuuids', () => {
it('should reject strings that are too short', () => {
expect(isValidShortUuid('tooshort')).toBe(false);
expect(isValidShortUuid('CXc85b4rqinB')).toBe(false);
});

it('should reject strings that are too long', () => {
expect(isValidShortUuid('CXc85b4rqinB7s5J52TRYbX')).toBe(false);
});

it('should reject strings with invalid characters', () => {
// All excluded base57 characters should be rejected
for (const char of BASE57_EXCLUDED_CHARS) {
const invalidUuid = `CXc85b4rqinB7s5J52TR${char}b`;
expect(isValidShortUuid(invalidUuid)).toBe(false);
}
});

it('should reject empty string', () => {
expect(isValidShortUuid('')).toBe(false);
});
});
});

describe('isValidSessionId', () => {
describe('valid session IDs', () => {
it('should accept standard UUIDs', () => {
expect(isValidSessionId('550e8400-e29b-41d4-a716-446655440000')).toBe(
true
);
expect(isValidSessionId('00000000-0000-4000-8000-000000000001')).toBe(
true
);
});

it('should accept shortuuids', () => {
expect(isValidSessionId('CXc85b4rqinB7s5J52TRYb')).toBe(true);
expect(isValidSessionId('vytxeTZskVKR7C7WgdSP3d')).toBe(true);
});

it('should accept hyphenated shortuuids', () => {
expect(isValidSessionId('CXc8-5b4r-qinB-7s5J-52TR-Yb')).toBe(true);
});
});

describe('invalid session IDs', () => {
it('should reject invalid formats', () => {
expect(isValidSessionId('invalid-session')).toBe(false);
expect(isValidSessionId('not-valid')).toBe(false);
});

it('should reject empty string', () => {
expect(isValidSessionId('')).toBe(false);
});

it('should reject strings that are neither UUID nor shortuuid', () => {
expect(isValidSessionId('12345')).toBe(false);
expect(isValidSessionId('abcdefghijklmnopqrstuvwxyz')).toBe(false);
});
});
});

describe('detectSessionIdFormat', () => {
it('should detect UUID format', () => {
expect(detectSessionIdFormat('550e8400-e29b-41d4-a716-446655440000')).toBe(
'uuid'
);
expect(detectSessionIdFormat('00000000-0000-4000-8000-000000000001')).toBe(
'uuid'
);
});

it('should detect shortuuid format', () => {
expect(detectSessionIdFormat('CXc85b4rqinB7s5J52TRYb')).toBe('shortuuid');
expect(detectSessionIdFormat('vytxeTZskVKR7C7WgdSP3d')).toBe('shortuuid');
});

it('should detect shortuuid with hyphens', () => {
expect(detectSessionIdFormat('CXc8-5b4r-qinB-7s5J-52TR-Yb')).toBe(
'shortuuid'
);
});

it('should return invalid for bad input', () => {
expect(detectSessionIdFormat('invalid-session')).toBe('invalid');
expect(detectSessionIdFormat('')).toBe('invalid');
expect(detectSessionIdFormat('12345')).toBe('invalid');
});
});

describe('normalizeSessionId', () => {
it('should preserve UUIDs unchanged', () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
expect(normalizeSessionId(uuid)).toBe(uuid);
});

it('should preserve shortuuids without hyphens', () => {
const shortuuid = 'CXc85b4rqinB7s5J52TRYb';
expect(normalizeSessionId(shortuuid)).toBe(shortuuid);
});

it('should strip hyphens from hyphenated shortuuids', () => {
expect(normalizeSessionId('CXc8-5b4r-qinB-7s5J-52TR-Yb')).toBe(
'CXc85b4rqinB7s5J52TRYb'
);
expect(normalizeSessionId('vy-txeT-ZskV-KR7C-7Wgd-SP3d')).toBe(
'vytxeTZskVKR7C7WgdSP3d'
);
});
});

describe('formatSessionIdForDisplay', () => {
describe('UUID formatting', () => {
it('should truncate UUIDs without adding hyphens', () => {
expect(
formatSessionIdForDisplay('550e8400-e29b-41d4-a716-446655440000', 8)
).toBe('550e8400');
expect(
formatSessionIdForDisplay('550e8400-e29b-41d4-a716-446655440000', 12)
).toBe('550e8400-e29');
});
});

describe('shortuuid formatting', () => {
it('should format shortuuids with truncation and hyphens', () => {
expect(formatSessionIdForDisplay('CXc85b4rqinB7s5J52TRYb', 8)).toBe(
'CXc8-5b4r'
);
expect(formatSessionIdForDisplay('vytxeTZskVKR7C7WgdSP3d', 8)).toBe(
'vytx-eTZs'
);
});

it('should format with truncate 12', () => {
expect(formatSessionIdForDisplay('CXc85b4rqinB7s5J52TRYb', 12)).toBe(
'CXc8-5b4r-qinB'
);
});

it('should handle already-hyphenated shortuuids', () => {
expect(formatSessionIdForDisplay('CXc8-5b4r-qinB-7s5J-52TR-Yb', 8)).toBe(
'CXc8-5b4r'
);
});

it('should format full shortuuid without truncation', () => {
// When truncate >= 22, shows full formatted output
expect(formatSessionIdForDisplay('CXc85b4rqinB7s5J52TRYb', 22)).toBe(
'CX-c85b-4rqi-nB7s-5J52-TRYb'
);
});
});
});
38 changes: 38 additions & 0 deletions cli/src/__tests__/utils/shortuuid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,44 @@ describe('shortuuid', () => {
it('should handle exactly 4 chars without hyphen', () => {
expect(formatDisplay('abcd')).toBe('abcd');
});

describe('truncate validation', () => {
it('should throw error for negative truncate', () => {
expect(() =>
formatDisplay('CXc85b4rqinB7s5J52TRYb', { truncate: -1 })
).toThrow('truncate must be a non-negative integer');
});

it('should throw error for non-integer truncate', () => {
expect(() =>
formatDisplay('CXc85b4rqinB7s5J52TRYb', { truncate: 8.5 })
).toThrow('truncate must be a non-negative integer');
});

it('should throw error for NaN truncate', () => {
expect(() =>
formatDisplay('CXc85b4rqinB7s5J52TRYb', { truncate: NaN })
).toThrow('truncate must be a non-negative integer');
});

it('should throw error for Infinity truncate', () => {
expect(() =>
formatDisplay('CXc85b4rqinB7s5J52TRYb', { truncate: Infinity })
).toThrow('truncate must be a non-negative integer');
});

it('should handle truncate: 0 (empty string)', () => {
expect(formatDisplay('CXc85b4rqinB7s5J52TRYb', { truncate: 0 })).toBe(
''
);
});

it('should handle very large truncate (returns full formatted)', () => {
expect(
formatDisplay('CXc85b4rqinB7s5J52TRYb', { truncate: 1000000 })
).toBe('CX-c85b-4rqi-nB7s-5J52-TRYb');
});
});
});

describe('isValid', () => {
Expand Down
9 changes: 9 additions & 0 deletions cli/src/utils/shortuuid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ function intToString(
const alphabetLength = BigInt(alphabet.length);
const digits: string[] = [];

// Edge case: number === 0n returns empty string before padding is applied.
// This is correct: UUID 00000000-0000-0000-0000-000000000000
// encodes to '2222222222222222222222' via padding (all first alphabet chars).
while (number > 0n) {
const remainder = number % alphabetLength;
number = number / alphabetLength;
Expand Down Expand Up @@ -171,6 +174,9 @@ export function decode(shortUuid: string): string {
*
* formatDisplay('vytxeTZskVKR7C7WgdSP3d', { truncate: 12 })
* // Returns: 'vytx-eTZs-kVKR'
*
* formatDisplay('vytxeTZskVKR7C7WgdSP3d', { truncate: 22 })
* // Returns: 'vy-txeT-ZskV-KR7C-7Wgd-SP3d' (full length, same as no truncate)
*/
export function formatDisplay(
shortUuid: string,
Expand All @@ -181,6 +187,9 @@ export function formatDisplay(

// Apply truncation if requested
if (options?.truncate !== undefined) {
if (!Number.isInteger(options.truncate) || options.truncate < 0) {
throw new Error('truncate must be a non-negative integer');
}
cleaned = cleaned.slice(0, options.truncate);
}

Expand Down