From 40cac08e10ac880b619026b01bbea2ba63770648 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Fri, 28 Feb 2025 00:33:10 -0700 Subject: [PATCH] feat: add more tests in utils folder- part 2 Signed-off-by: Calvin Lee --- .../src/utils/__tests__/error-utils.test.ts | 42 ++++ .../utils/__tests__/execution-utils.test.tsx | 123 +++++++++++ .../src/utils/__tests__/git-utils.test.ts | 183 ++++++++++++++++ .../src/utils/__tests__/path-utils.test.ts | 87 ++++++++ .../__tests__/repo-branch-rules-utils.test.ts | 200 ++++++++++++++++++ 5 files changed, 635 insertions(+) create mode 100644 apps/gitness/src/utils/__tests__/error-utils.test.ts create mode 100644 apps/gitness/src/utils/__tests__/execution-utils.test.tsx create mode 100644 apps/gitness/src/utils/__tests__/git-utils.test.ts create mode 100644 apps/gitness/src/utils/__tests__/path-utils.test.ts create mode 100644 apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts diff --git a/apps/gitness/src/utils/__tests__/error-utils.test.ts b/apps/gitness/src/utils/__tests__/error-utils.test.ts new file mode 100644 index 000000000..32ae0d1ce --- /dev/null +++ b/apps/gitness/src/utils/__tests__/error-utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { getErrorMessage } from '../error-utils' + +// Define a custom error type for testing +interface CustomError { + data?: { + error?: string + message?: string + } + message?: string +} + +describe('getErrorMessage', () => { + it('should handle undefined error', () => { + expect(getErrorMessage(undefined)).toBeUndefined() + }) + + it('should handle null error', () => { + expect(getErrorMessage(null)).toBeUndefined() + }) + + it('should extract error from data.error', () => { + const error: CustomError = { data: { error: 'Test error message' } } + expect(getErrorMessage(error)).toBe('Test error message') + }) + + it('should extract error from data.message', () => { + const error: CustomError = { data: { message: 'Test message' } } + expect(getErrorMessage(error)).toBe('Test message') + }) + + it('should extract error from message', () => { + const error: CustomError = { message: 'Direct message' } + expect(getErrorMessage(error)).toBe('Direct message') + }) + + it('should return error as string if no structured format is found', () => { + const error = 'Plain error string' + expect(getErrorMessage(error)).toBe('Plain error string') + }) +}) diff --git a/apps/gitness/src/utils/__tests__/execution-utils.test.tsx b/apps/gitness/src/utils/__tests__/execution-utils.test.tsx new file mode 100644 index 000000000..1cbbb2066 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/execution-utils.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { EnumCiStatus, TypesExecution } from '@harnessio/code-service-client' +import { MeterState } from '@harnessio/ui/components' +import { PipelineExecutionStatus } from '@harnessio/ui/views' + +import { getExecutionStatus, getLabel, getMeterState } from '../execution-utils' + +describe('getLabel', () => { + it('should return empty string for missing author_name or event', () => { + const baseExecution: TypesExecution = { + number: 1, + status: 'success' as EnumCiStatus, + created: 0, + updated: 0, + started: 0, + finished: 0 + } + + expect(getLabel({ ...baseExecution, author_name: '', event: undefined })).toBe('') + expect(getLabel({ ...baseExecution, author_name: 'test', event: undefined })).toBe('') + expect(getLabel({ ...baseExecution, author_name: '', event: 'manual' })).toBe('') + }) + + it('should handle manual event', () => { + const execution: TypesExecution = { + author_name: 'John Doe', + event: 'manual', + number: 1, + status: 'success' as EnumCiStatus, + created: 0, + updated: 0, + started: 0, + finished: 0 + } + expect(getLabel(execution)).toBe('John Doe triggered manually') + }) + + it('should handle pull_request event with source and target', () => { + const execution: TypesExecution = { + author_name: 'John Doe', + event: 'pull_request', + source: 'feature', + target: 'main', + number: 1, + status: 'success' as EnumCiStatus, + created: 0, + updated: 0, + started: 0, + finished: 0 + } + const result = getLabel(execution) + expect(result).toBeTruthy() // Since it returns a React element, we just verify it's not null + }) +}) +describe('getExecutionStatus', () => { + it('should map running status correctly', () => { + expect(getExecutionStatus('running')).toBe(PipelineExecutionStatus.RUNNING) + }) + + it('should map success status correctly', () => { + expect(getExecutionStatus('success')).toBe(PipelineExecutionStatus.SUCCESS) + }) + + it('should map failure status correctly', () => { + expect(getExecutionStatus('failure')).toBe(PipelineExecutionStatus.FAILURE) + }) + + it('should map error status correctly', () => { + expect(getExecutionStatus('error')).toBe(PipelineExecutionStatus.ERROR) + }) + + it('should map killed status correctly', () => { + expect(getExecutionStatus('killed')).toBe(PipelineExecutionStatus.KILLED) + }) + + it('should return UNKNOWN for undefined status', () => { + expect(getExecutionStatus(undefined)).toBe(PipelineExecutionStatus.UNKNOWN) + }) + + it('should return UNKNOWN for invalid status', () => { + const invalidStatus = 'invalid' as EnumCiStatus + expect(getExecutionStatus(invalidStatus)).toBe(PipelineExecutionStatus.UNKNOWN) + }) +}) + +describe('getMeterState', () => { + it('should return Error state for failure status', () => { + expect(getMeterState(PipelineExecutionStatus.FAILURE)).toBe(MeterState.Error) + }) + + it('should return Error state for killed status', () => { + expect(getMeterState(PipelineExecutionStatus.KILLED)).toBe(MeterState.Error) + }) + + it('should return Error state for error status', () => { + expect(getMeterState(PipelineExecutionStatus.ERROR)).toBe(MeterState.Error) + }) + + it('should return Success state for success status', () => { + expect(getMeterState(PipelineExecutionStatus.SUCCESS)).toBe(MeterState.Success) + }) + + it('should return Warning state for skipped status', () => { + expect(getMeterState(PipelineExecutionStatus.SKIPPED)).toBe(MeterState.Warning) + }) + + it('should return Warning state for blocked status', () => { + expect(getMeterState(PipelineExecutionStatus.BLOCKED)).toBe(MeterState.Warning) + }) + + it('should return Empty state for pending status', () => { + expect(getMeterState(PipelineExecutionStatus.PENDING)).toBe(MeterState.Empty) + }) + + it('should return Empty state for waiting on dependencies status', () => { + expect(getMeterState(PipelineExecutionStatus.WAITING_ON_DEPENDENCIES)).toBe(MeterState.Empty) + }) + + it('should return Empty state for undefined status', () => { + expect(getMeterState(undefined)).toBe(MeterState.Empty) + }) +}) diff --git a/apps/gitness/src/utils/__tests__/git-utils.test.ts b/apps/gitness/src/utils/__tests__/git-utils.test.ts new file mode 100644 index 000000000..09e054bc7 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/git-utils.test.ts @@ -0,0 +1,183 @@ +import langMap from 'lang-map' +import { describe, expect, it, vi } from 'vitest' + +import { + decodeGitContent, + filenameToLanguage, + formatBytes, + getTrimmedSha, + GitCommitAction, + isGitRev, + isRefABranch, + isRefATag, + normalizeGitRef, + REFS_BRANCH_PREFIX, + REFS_TAGS_PREFIX +} from '../git-utils' + +describe('formatBytes', () => { + it('should format bytes correctly', () => { + expect(formatBytes(0)).toBe('0 Bytes') + expect(formatBytes(1024)).toBe('1 KB') + expect(formatBytes(1024 * 1024)).toBe('1 MB') + expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB') + }) + + it('should handle decimal places correctly', () => { + expect(formatBytes(1234, 1)).toBe('1.2 KB') + expect(formatBytes(1234, 0)).toBe('1 KB') + expect(formatBytes(1234, 3)).toBe('1.205 KB') + }) + + it('should handle negative decimals', () => { + expect(formatBytes(1234, -1)).toBe('1 KB') + }) +}) + +describe('decodeGitContent', () => { + const mockConsoleError = vi.spyOn(console, 'error') + const mockAtob = vi.spyOn(window, 'atob') + + beforeEach(() => { + mockConsoleError.mockImplementation(() => {}) + mockAtob.mockImplementation(str => Buffer.from(str, 'base64').toString()) + }) + + afterEach(() => { + mockConsoleError.mockRestore() + mockAtob.mockRestore() + }) + + it('should decode base64 content correctly', () => { + const base64Content = Buffer.from('Hello World').toString('base64') + expect(decodeGitContent(base64Content)).toBe('Hello World') + }) + + it('should handle empty content', () => { + expect(decodeGitContent()).toBe('') + expect(decodeGitContent('')).toBe('') + }) + + it('should handle invalid base64 content', () => { + expect(decodeGitContent('invalid-base64')).toBe('invalid-base64') + }) + + it('should handle decoding errors', () => { + mockAtob.mockImplementation(() => { + throw new Error('Decoding error') + }) + expect(decodeGitContent('error-content')).toBe('error-content') + expect(mockConsoleError).toHaveBeenCalled() + }) +}) + +describe('filenameToLanguage', () => { + const mockLanguagesMap = new Map([ + ['js', ['javascript']], + ['py', ['python']], + ['go', ['go']], + ['ts', ['typescript']] + ]) + + const mockLanguages = vi.spyOn(langMap, 'languages') + + beforeEach(() => { + mockLanguages.mockImplementation(ext => mockLanguagesMap.get(ext) || []) + }) + + afterEach(() => { + mockLanguages.mockRestore() + }) + + it('should detect common file extensions', () => { + expect(filenameToLanguage('test.js')).toBe('javascript') + expect(filenameToLanguage('test.py')).toBe('python') + expect(filenameToLanguage('test.go')).toBe('go') + expect(filenameToLanguage('test.ts')).toBe('typescript') + }) + + it('should map special extensions correctly', () => { + expect(filenameToLanguage('test.jsx')).toBe('typescript') + expect(filenameToLanguage('test.tsx')).toBe('typescript') + expect(filenameToLanguage('.gitignore')).toBe('shell') + expect(filenameToLanguage('Dockerfile')).toBe('dockerfile') + }) + + it('should handle extensions from lang-map that match Monaco supported languages', () => { + mockLanguages.mockReturnValueOnce(['typescript', 'javascript']) + expect(filenameToLanguage('test.custom')).toBe('typescript') + }) + + it('should return plaintext for unknown extensions', () => { + expect(filenameToLanguage('test.unknown')).toBe('plaintext') + expect(filenameToLanguage('')).toBe('plaintext') + expect(filenameToLanguage(undefined)).toBe('plaintext') + }) +}) + +describe('Git Reference Functions', () => { + describe('isRefATag', () => { + it('should identify tag references', () => { + expect(isRefATag(REFS_TAGS_PREFIX + 'v1.0.0')).toBe(true) + expect(isRefATag('v1.0.0')).toBe(false) + expect(isRefATag(undefined)).toBe(false) + }) + }) + + describe('isRefABranch', () => { + it('should identify branch references', () => { + expect(isRefABranch(REFS_BRANCH_PREFIX + 'main')).toBe(true) + expect(isRefABranch('main')).toBe(false) + expect(isRefABranch(undefined)).toBe(false) + }) + }) + + describe('isGitRev', () => { + it('should identify valid git commit hashes', () => { + expect(isGitRev('1234567')).toBe(true) + expect(isGitRev('1234567890abcdef1234567890abcdef12345678')).toBe(true) + expect(isGitRev('123456')).toBe(false) // Too short + expect(isGitRev('123456g')).toBe(false) // Invalid character + expect(isGitRev('')).toBe(false) + }) + }) + + describe('normalizeGitRef', () => { + it('should handle undefined input', () => { + expect(normalizeGitRef(undefined)).toBe('refs/heads/undefined') + }) + + it('should normalize git references correctly', () => { + const tag = REFS_TAGS_PREFIX + 'v1.0.0' + const branch = REFS_BRANCH_PREFIX + 'main' + const commit = '1234567890abcdef1234567890abcdef12345678' + + expect(normalizeGitRef(tag)).toBe(tag) + expect(normalizeGitRef(branch)).toBe(branch) + expect(normalizeGitRef(commit)).toBe(commit) + expect(normalizeGitRef('main')).toBe('refs/heads/main') + expect(normalizeGitRef('')).toBe('') + }) + }) +}) + +describe('getTrimmedSha', () => { + it('should trim SHA to 7 characters', () => { + const fullSha = '1234567890abcdef1234567890abcdef12345678' + expect(getTrimmedSha(fullSha)).toBe('1234567') + }) + + it('should handle short SHA', () => { + const shortSha = '1234567' + expect(getTrimmedSha(shortSha)).toBe('1234567') + }) +}) + +describe('GitCommitAction enum', () => { + it('should have correct enum values', () => { + expect(GitCommitAction.DELETE).toBe('DELETE') + expect(GitCommitAction.CREATE).toBe('CREATE') + expect(GitCommitAction.UPDATE).toBe('UPDATE') + expect(GitCommitAction.MOVE).toBe('MOVE') + }) +}) diff --git a/apps/gitness/src/utils/__tests__/path-utils.test.ts b/apps/gitness/src/utils/__tests__/path-utils.test.ts new file mode 100644 index 000000000..7e89987a3 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/path-utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { splitPathWithParents } from '../path-utils' + +describe('splitPathWithParents', () => { + it('should return empty array for empty path', () => { + expect(splitPathWithParents('', 'repo')).toEqual([]) + }) + + it('should split single level path correctly', () => { + const result = splitPathWithParents('file.txt', 'repo') + expect(result).toEqual([ + { + path: 'file.txt', + parentPath: 'repo/~/file.txt' + } + ]) + }) + + it('should split multi-level path correctly', () => { + const result = splitPathWithParents('folder/subfolder/file.txt', 'repo') + expect(result).toEqual([ + { + path: 'folder', + parentPath: 'repo/~/folder' + }, + { + path: 'subfolder', + parentPath: 'repo/~/folder/subfolder' + }, + { + path: 'file.txt', + parentPath: 'repo/~/folder/subfolder/file.txt' + } + ]) + }) + + it('should handle paths with leading slash', () => { + const result = splitPathWithParents('/folder/file.txt', 'repo') + expect(result).toEqual([ + { + path: '', + parentPath: 'repo/~/' + }, + { + path: 'folder', + parentPath: 'repo/~/folder' + }, + { + path: 'file.txt', + parentPath: 'repo/~/folder/file.txt' + } + ]) + }) + + it('should handle paths with trailing slash', () => { + const result = splitPathWithParents('folder/subfolder/', 'repo') + expect(result).toEqual([ + { + path: 'folder', + parentPath: 'repo/~/folder' + }, + { + path: 'subfolder', + parentPath: 'repo/~/folder/subfolder' + }, + { + path: '', + parentPath: 'repo/~/folder/subfolder/' + } + ]) + }) + + it('should handle special characters in paths', () => { + const result = splitPathWithParents('folder-name/file_name.txt', 'repo-name') + expect(result).toEqual([ + { + path: 'folder-name', + parentPath: 'repo-name/~/folder-name' + }, + { + path: 'file_name.txt', + parentPath: 'repo-name/~/folder-name/file_name.txt' + } + ]) + }) +}) diff --git a/apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts b/apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts new file mode 100644 index 000000000..f59c74f68 --- /dev/null +++ b/apps/gitness/src/utils/__tests__/repo-branch-rules-utils.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' + +import { EnumRuleState, type RepoRuleGetOkResponse } from '@harnessio/code-service-client' +import { BranchRuleId, MergeStrategy, PatternsButtonType, Rule } from '@harnessio/ui/views' + +import { getTotalRulesApplied, transformDataFromApi, transformFormOutput } from '../repo-branch-rules-utils' + +const mockApiResponse: RepoRuleGetOkResponse = { + identifier: 'test-rule', + description: 'Test rule description', + state: 'active' as EnumRuleState, + pattern: { + default: true, + include: ['main/*'], + exclude: ['main/excluded'] + }, + definition: { + bypass: { + user_ids: [1, 2], + repo_owners: true + }, + lifecycle: { + create_forbidden: true, + delete_forbidden: false, + update_forbidden: true + }, + pullreq: { + approvals: { + require_code_owners: true, + require_latest_commit: true, + require_no_change_request: false, + require_minimum_count: 2 + }, + comments: { + require_resolve_all: true + }, + merge: { + strategies_allowed: ['merge', 'rebase'] as MergeStrategy[], + delete_branch: true + }, + status_checks: { + require_identifiers: ['check1', 'check2'] + } + } + }, + users: { + user1: { display_name: 'User One' }, + user2: { display_name: 'User Two' } + } +} + +const mockFormOutput = { + identifier: 'test-rule', + description: 'Test rule description', + pattern: '', + patterns: [ + { pattern: 'main/*', option: PatternsButtonType.INCLUDE }, + { pattern: 'main/excluded', option: PatternsButtonType.EXCLUDE } + ], + state: true, + bypass: [ + { id: 1, display_name: 'User One' }, + { id: 2, display_name: 'User Two' } + ], + default: true, + repo_owners: true, + rules: [ + { id: BranchRuleId.REQUIRE_LATEST_COMMIT, checked: true }, + { id: BranchRuleId.REQUIRE_NO_CHANGE_REQUEST, checked: false }, + { id: BranchRuleId.COMMENTS, checked: true }, + { id: BranchRuleId.STATUS_CHECKS, checked: true, selectOptions: ['check1', 'check2'] }, + { id: BranchRuleId.MERGE, checked: true, submenu: ['merge', 'rebase'] }, + { id: BranchRuleId.DELETE_BRANCH, checked: true }, + { id: BranchRuleId.BLOCK_BRANCH_CREATION, checked: true }, + { id: BranchRuleId.BLOCK_BRANCH_DELETION, checked: false }, + { id: BranchRuleId.REQUIRE_PULL_REQUEST, checked: true }, + { id: BranchRuleId.REQUIRE_CODE_REVIEW, checked: true, input: '2' }, + { id: BranchRuleId.REQUIRE_CODE_OWNERS, checked: true } + ] as unknown as Rule[] +} + +describe('transformDataFromApi', () => { + it('should transform API response to form data correctly', () => { + const result = transformDataFromApi(mockApiResponse) + + expect(result.identifier).toBe('test-rule') + expect(result.description).toBe('Test rule description') + expect(result.state).toBe(true) + expect(result.default).toBe(true) + expect(result.repo_owners).toBe(true) + + // Check patterns + expect(result.patterns).toEqual([ + { pattern: 'main/*', option: PatternsButtonType.INCLUDE }, + { pattern: 'main/excluded', option: PatternsButtonType.EXCLUDE } + ]) + + // Check bypass users + expect(result.bypass).toEqual([ + { id: 'user1', display_name: 'User One' }, + { id: 'user2', display_name: 'User Two' } + ]) + + // Check rules + const ruleMap = new Map(result.rules.map(rule => [rule.id, rule])) + + expect(ruleMap.get(BranchRuleId.REQUIRE_LATEST_COMMIT)?.checked).toBe(true) + expect(ruleMap.get(BranchRuleId.REQUIRE_CODE_REVIEW)?.checked).toBe(true) + expect(ruleMap.get(BranchRuleId.REQUIRE_CODE_REVIEW)?.input).toBe('2') + expect(ruleMap.get(BranchRuleId.STATUS_CHECKS)?.selectOptions).toEqual(['check1', 'check2']) + expect(ruleMap.get(BranchRuleId.MERGE)?.submenu).toEqual(['merge', 'rebase']) + }) + + it('should handle empty API response', () => { + const emptyResponse = { + identifier: '', + state: 'disabled' as EnumRuleState + } + + const result = transformDataFromApi(emptyResponse) + + expect(result.identifier).toBe('') + expect(result.description).toBe('') + expect(result.state).toBe(false) + expect(result.patterns).toEqual([]) + expect(result.bypass).toEqual([]) + }) +}) + +describe('transformFormOutput', () => { + it('should transform form data to API request format correctly', () => { + const result = transformFormOutput(mockFormOutput) + + expect(result?.identifier).toBe('test-rule') + expect(result?.description).toBe('Test rule description') + expect(result?.state).toBe('active') + expect(result?.pattern?.default).toBe(true) + + // Check patterns + expect(result?.pattern?.include).toEqual(['main/*']) + expect(result?.pattern?.exclude).toEqual(['main/excluded']) + + // Check bypass + expect(result?.definition?.bypass?.user_ids).toEqual(['user1', 'user2']) + expect(result?.definition?.bypass?.repo_owners).toBe(true) + + // Check lifecycle rules + expect(result?.definition?.lifecycle?.create_forbidden).toBe(true) + expect(result?.definition?.lifecycle?.delete_forbidden).toBe(false) + expect(result?.definition?.lifecycle?.update_forbidden).toBe(true) + + // Check pull request rules + expect(result?.definition?.pullreq?.approvals?.require_code_owners).toBe(true) + expect(result?.definition?.pullreq?.approvals?.require_minimum_count).toBe(2) + expect(result?.definition?.pullreq?.merge?.strategies_allowed).toEqual(['merge', 'rebase']) + expect(result?.definition?.pullreq?.status_checks?.require_identifiers).toEqual(['check1', 'check2']) + }) +}) + +describe('getTotalRulesApplied', () => { + it('should count total number of enabled rules correctly', () => { + const total = getTotalRulesApplied(mockApiResponse) + expect(total).toBe(9) // Count of all rules with checked=true in mockApiResponse + }) + + it('should return 0 for no enabled rules', () => { + const emptyRules = { + ...mockApiResponse, + definition: { + ...mockApiResponse.definition, + lifecycle: { + create_forbidden: false, + delete_forbidden: false, + update_forbidden: false + }, + pullreq: { + approvals: { + require_code_owners: false, + require_latest_commit: false, + require_no_change_request: false, + require_minimum_count: 0 + }, + comments: { + require_resolve_all: false + }, + merge: { + strategies_allowed: [], + delete_branch: false + }, + status_checks: { + require_identifiers: [] + } + } + } + } + + const total = getTotalRulesApplied(emptyRules) + expect(total).toBe(0) + }) +})