real-github-integration.test.ts•19.1 kB
/**
* Real GitHub Integration Tests
* These tests perform ACTUAL GitHub API operations - NO MOCKS!
*
* NOTE: These tests are skipped in CI environments to prevent conflicts
* when multiple CI runs attempt to modify the same test repository simultaneously.
* The tests should be run locally during development to verify GitHub integration.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from '@jest/globals';
import { setupTestEnvironment, TestEnvironment, ERROR_CODES } from './setup-test-env.js';
import { GitHubTestClient } from '../utils/github-api-client.js';
import {
createZiggyTestPersona,
createTestPersona,
createTestPersonaSet
} from '../utils/test-persona-factory.js';
import { PortfolioRepoManager } from '../../src/portfolio/PortfolioRepoManager.js';
import { retryWithBackoff, retryIfRetryable } from './utils/retry.js';
// Skip the entire test suite in CI environments to prevent conflicts
const describeOrSkip = process.env.CI ? describe.skip : describe;
describeOrSkip('Real GitHub Portfolio Integration Tests', () => {
let testEnv: TestEnvironment;
let githubClient: GitHubTestClient;
let portfolioManager: PortfolioRepoManager;
let uploadedFiles: string[] = [];
beforeAll(async () => {
console.log('\n🚀 Starting real GitHub integration tests...\n');
// Setup and validate environment
testEnv = await setupTestEnvironment();
// Skip tests if no token available
if (testEnv.skipTests) {
console.log('⏭️ Skipping GitHub integration tests - no token available');
return;
}
githubClient = new GitHubTestClient(testEnv);
// Initialize portfolio manager with real token
portfolioManager = new PortfolioRepoManager();
portfolioManager.setToken(testEnv.githubToken);
console.log(`\n📋 Test Configuration:`);
console.log(` Repository: ${testEnv.testRepo}`);
console.log(` User: ${testEnv.githubUser}`);
console.log(` Cleanup: ${testEnv.cleanupAfter ? 'Yes' : 'No'}`);
console.log(` Branch: ${testEnv.testBranch}\n`);
}, 60000); // Longer timeout for setup
afterEach(async () => {
// Track files for cleanup
if (testEnv?.cleanupAfter && uploadedFiles.length > 0 && githubClient) {
console.log(`\n🧹 Cleaning up ${uploadedFiles.length} test files...`);
for (const file of uploadedFiles) {
await githubClient.deleteFile(file);
}
uploadedFiles = [];
}
});
afterAll(async () => {
console.log('\n✅ GitHub integration tests completed\n');
});
describe('Single Element Upload - Success Path', () => {
it('should successfully upload a single persona to GitHub and verify it exists', async () => {
// Skip test if no token available
if (testEnv.skipTests) {
console.log('⏭️ Test skipped - no GitHub token');
return;
}
console.log('\n▶️ Test: Upload single persona to GitHub');
// Step 1: Create test persona
console.log(' 1️⃣ Creating test persona...');
const ziggyPersona = createZiggyTestPersona({
author: testEnv.githubUser,
prefix: testEnv.personaPrefix
});
// Step 2: Upload to GitHub via PortfolioRepoManager
console.log(' 2️⃣ Uploading to GitHub...');
const uploadResult = await retryIfRetryable(
async () => await portfolioManager.saveElement(ziggyPersona, true),
{
maxAttempts: 5, // Increased from 3 to handle aggressive CI concurrency
onRetry: (attempt, error) => console.log(` ↻ Retry ${attempt} due to: ${error.message}`)
}
);
expect(uploadResult).toBeTruthy();
expect(uploadResult).toContain('github.com');
console.log(` ✅ Upload successful: ${uploadResult}`);
// Step 3: Extract file path from result
// Use the actual generateFileName method for consistency
const fileName = PortfolioRepoManager.generateFileName(ziggyPersona.metadata.name || 'unnamed');
const filePath = `personas/${fileName}.md`;
uploadedFiles.push(filePath);
// Step 4: Verify file exists on GitHub by fetching it
console.log(' 3️⃣ Verifying file exists on GitHub...');
// Use retry logic for eventual consistency
const githubFile = await retryWithBackoff(
async () => {
const file = await githubClient.getFile(filePath);
if (!file) {
throw new Error(`File not found at ${filePath}`);
}
return file;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 5000,
onRetry: (attempt, _error, delayMs) => {
console.log(` ⏳ Retry ${attempt}/3: Waiting ${delayMs}ms for GitHub to process file...`);
}
}
);
expect(githubFile).not.toBeNull();
expect(githubFile?.content).toBeTruthy();
console.log(` ✅ File verified at: ${filePath}`);
// Step 5: Compare uploaded content with original
console.log(' 4️⃣ Comparing content...');
expect(githubFile?.content).toContain('Test Ziggy');
expect(githubFile?.content).toContain('Quantum Leap');
console.log(' ✅ Content matches original');
// Step 6: Verify URL is accessible (not 404)
console.log(' 5️⃣ Verifying URL is accessible...');
const urlAccessible = await githubClient.verifyUrl(uploadResult);
expect(urlAccessible).toBe(true);
console.log(' ✅ URL is accessible (not 404)');
console.log('\n✅ Single element upload test PASSED');
}, 60000);
it('should handle GitHub response with null commit field correctly', async () => {
// Skip if no GitHub token
if (testEnv.skipTests) {
console.log('⏭️ Skipping test - no GitHub token available');
return;
}
console.log('\n▶️ Test: Handle null commit in GitHub response');
// This tests the specific bug from the QA report
// We'll upload a file and verify it works even if commit is null
const testPersona = createTestPersona({
author: testEnv.githubUser,
prefix: testEnv.personaPrefix,
name: `${testEnv.personaPrefix}null-commit-test-${Date.now()}`
});
console.log(' 1️⃣ Uploading test persona...');
const result = await retryIfRetryable(
async () => await portfolioManager.saveElement(testPersona, true),
{
maxAttempts: 5, // Increased from 3 to handle aggressive CI concurrency
onRetry: (attempt, error) => console.log(` ↻ Retry ${attempt} due to: ${error.message}`)
}
);
// Even with potential null commit, should return a valid URL
expect(result).toBeTruthy();
expect(result).toContain('github.com');
expect(result).not.toContain('null');
expect(result).not.toContain('undefined');
// Use the actual generateFileName method from PortfolioRepoManager for consistency
const fileName = PortfolioRepoManager.generateFileName(testPersona.metadata.name || 'unnamed');
const filePath = `personas/${fileName}.md`;
uploadedFiles.push(filePath);
console.log(` ✅ Handled response correctly: ${result}`);
console.log(` 📁 Expected file path: ${filePath}`);
// Verify file actually exists with proper retry logic
// GitHub API may have eventual consistency, so we retry with exponential backoff
const file = await retryWithBackoff(
async () => {
const fetchedFile = await githubClient.getFile(filePath);
if (!fetchedFile) {
throw new Error(`File not found at ${filePath}`);
}
return fetchedFile;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 5000,
onRetry: (attempt, _error, delayMs) => {
console.log(` ⏳ Retry ${attempt}/3: File not yet available, waiting ${delayMs}ms...`);
}
}
);
expect(file).not.toBeNull();
expect(file.content).toBeTruthy();
console.log(' ✅ File exists on GitHub despite response variations');
}, 30000);
});
describe('Error Code Validation', () => {
it('should return PORTFOLIO_SYNC_001 for invalid token', async () => {
// Skip if no GitHub token
if (testEnv.skipTests) {
console.log('⏭️ Skipping test - no GitHub token available');
return;
}
console.log('\n▶️ Test: Invalid token error (SYNC_001)');
const badManager = new PortfolioRepoManager();
badManager.setToken('ghp_invalid_token_xxx');
const testPersona = createTestPersona();
await expect(badManager.saveElement(testPersona, true))
.rejects
.toThrow(/PORTFOLIO_SYNC_001|401|authentication|unauthorized/i);
console.log(' ✅ Correctly returned PORTFOLIO_SYNC_001 for bad token');
}, 30000);
it('should handle rate limit errors gracefully', async () => {
// Skip if no GitHub token
if (testEnv.skipTests) {
console.log('⏭️ Skipping test - no GitHub token available');
return;
}
console.log('\n▶️ Test: Rate limit handling');
// Check current rate limit
const rateLimit = await githubClient.getRateLimit();
console.log(` Current rate limit: ${rateLimit.remaining} remaining`);
if (rateLimit.remaining < 10) {
console.log(' ⚠️ Rate limit too low, skipping aggressive test');
return;
}
// This won't trigger actual rate limit but tests the handling
const testPersona = createTestPersona({
author: testEnv.githubUser,
prefix: testEnv.personaPrefix,
name: `${testEnv.personaPrefix}rate-limit-test-${Date.now()}`
});
const result = await retryIfRetryable(
async () => await portfolioManager.saveElement(testPersona, true),
{
maxAttempts: 5, // Increased from 3 to handle aggressive CI concurrency
onRetry: (attempt, error) => console.log(` ↻ Retry ${attempt} due to: ${error.message}`)
}
);
expect(result).toBeTruthy();
console.log(' ✅ Rate limit handling verified');
// Use the actual generateFileName method from PortfolioRepoManager for consistency
const fileName = PortfolioRepoManager.generateFileName(testPersona.metadata.name || 'unnamed');
const filePath = `personas/${fileName}.md`;
uploadedFiles.push(filePath);
}, 30000);
});
describe('Bulk Sync Prevention', () => {
it('should upload ONLY the specified element, not all personas', async () => {
// Skip if no GitHub token
if (testEnv.skipTests) {
console.log('⏭️ Skipping test - no GitHub token available');
return;
}
console.log('\n▶️ Test: Single upload does not trigger bulk sync');
// Create multiple personas
const personas = createTestPersonaSet({
author: testEnv.githubUser,
prefix: testEnv.personaPrefix
});
console.log(` 1️⃣ Created ${personas.length} test personas (1 public, 2 private)`);
// Upload only the public one
const publicPersona = personas[0];
console.log(' 2️⃣ Uploading ONLY the public persona...');
const result = await retryIfRetryable(
async () => await portfolioManager.saveElement(publicPersona, true),
{
maxAttempts: 5, // Increased from 3 to handle aggressive CI concurrency
onRetry: (attempt, error) => console.log(` ↻ Retry ${attempt} due to: ${error.message}`)
}
);
expect(result).toBeTruthy();
// Use the actual generateFileName method for consistency
const publicFileName = PortfolioRepoManager.generateFileName(publicPersona.metadata.name || 'unnamed');
const publicPath = `personas/${publicFileName}.md`;
uploadedFiles.push(publicPath);
// Verify ONLY the public persona was uploaded
console.log(' 3️⃣ Verifying only ONE file was uploaded...');
// Check that private personas were NOT uploaded
for (let i = 1; i < personas.length; i++) {
const privatePersona = personas[i];
const privateFileName = PortfolioRepoManager.generateFileName(privatePersona.metadata.name || 'unnamed');
const privatePath = `personas/${privateFileName}.md`;
const privateFile = await githubClient.getFile(privatePath);
expect(privateFile).toBeNull();
console.log(` ✅ Private persona ${i} was NOT uploaded`);
}
// List all files in personas directory
const allFiles = await githubClient.listFiles('personas');
const testFiles = allFiles.filter(f => testEnv.personaPrefix && f.includes(testEnv.personaPrefix));
console.log(` 📁 Test files in personas/: ${testFiles.length}`);
console.log(' ✅ Confirmed: Only requested element was uploaded');
}, 60000);
});
describe('URL Extraction Fallbacks', () => {
it('should generate correct URLs with various response formats', async () => {
// Skip if no GitHub token
if (testEnv.skipTests) {
console.log('⏭️ Skipping test - no GitHub token available');
return;
}
console.log('\n▶️ Test: URL extraction with fallbacks');
const testPersona = createTestPersona({
author: testEnv.githubUser,
prefix: testEnv.personaPrefix,
name: `${testEnv.personaPrefix}url-test-${Date.now()}`
});
console.log(' 1️⃣ Uploading test persona...');
const result = await retryIfRetryable(
async () => await portfolioManager.saveElement(testPersona, true),
{
maxAttempts: 5, // Increased from 3 to handle aggressive CI concurrency
onRetry: (attempt, error) => console.log(` ↻ Retry ${attempt} due to: ${error.message}`)
}
);
// Verify URL format
expect(result).toMatch(/https:\/\/github\.com\/.+/);
expect(result).not.toContain('undefined');
expect(result).not.toContain('null');
console.log(` ✅ Generated valid URL: ${result}`);
// Verify the URL actually works
const urlWorks = await githubClient.verifyUrl(result);
expect(urlWorks).toBe(true);
console.log(' ✅ URL is accessible');
// Use the actual generateFileName method from PortfolioRepoManager for consistency
const fileName = PortfolioRepoManager.generateFileName(testPersona.metadata.name || 'unnamed');
const filePath = `personas/${fileName}.md`;
uploadedFiles.push(filePath);
}, 30000);
});
describe('Real User Flow Simulation', () => {
it('should complete the exact flow a user would follow', async () => {
// Skip if no GitHub token
if (testEnv.skipTests) {
console.log('⏭️ Skipping test - no GitHub token available');
return;
}
console.log('\n▶️ Test: Complete user flow simulation');
console.log(' Simulating: User wants to upload Ziggy persona to GitHub portfolio');
// Step 1: User has multiple personas locally
console.log('\n 1️⃣ User has multiple personas in local portfolio:');
const localPersonas = [
{ name: 'Ziggy', description: 'Quantum Leap AI', private: false },
{ name: 'Work Assistant', description: 'Private work helper', private: true },
{ name: 'Family Helper', description: 'Personal assistant', private: true }
];
localPersonas.forEach(p => {
console.log(` - ${p.name} (${p.private ? 'private' : 'public'})`);
});
// Step 2: User chooses to upload only Ziggy
console.log('\n 2️⃣ User action: "Upload Ziggy to my GitHub portfolio"');
const ziggyPersona = createZiggyTestPersona({
author: testEnv.githubUser,
prefix: testEnv.personaPrefix
});
// Step 3: System uploads to GitHub
console.log('\n 3️⃣ System uploading to GitHub...');
const startTime = Date.now();
const uploadUrl = await retryIfRetryable(
async () => await portfolioManager.saveElement(ziggyPersona, true),
{
maxAttempts: 5, // Increased from 3 to handle aggressive CI concurrency
onRetry: (attempt, error) => console.log(` ↻ Retry ${attempt} due to: ${error.message}`)
}
);
const uploadTime = Date.now() - startTime;
expect(uploadUrl).toBeTruthy();
console.log(` ✅ Upload complete in ${uploadTime}ms`);
console.log(` 📍 URL: ${uploadUrl}`);
// Step 4: User wants to verify it's really there
console.log('\n 4️⃣ User verification: "Is it really on GitHub?"');
// Use the actual generateFileName method for consistency
const fileName = PortfolioRepoManager.generateFileName(ziggyPersona.metadata.name || 'unnamed');
const filePath = `personas/${fileName}.md`;
uploadedFiles.push(filePath);
// Verify with retry for eventual consistency
const githubFile = await retryWithBackoff(
async () => {
const file = await githubClient.getFile(filePath);
if (!file) {
throw new Error(`File not found at ${filePath}`);
}
return file;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 5000,
onRetry: (attempt, _error, delayMs) => {
console.log(` ⏳ Retry ${attempt}/3: Waiting ${delayMs}ms for file to be available...`);
}
}
);
expect(githubFile).not.toBeNull();
expect(githubFile?.content).toContain('Ziggy');
console.log(' ✅ Yes! File exists on GitHub');
// Step 5: User checks that private personas were NOT uploaded
console.log('\n 5️⃣ User concern: "Did my private personas stay private?"');
const privateFiles = [
'personas/work-assistant.md',
'personas/family-helper.md'
];
for (const privateFile of privateFiles) {
const exists = await githubClient.getFile(privateFile);
expect(exists).toBeNull();
}
console.log(' ✅ Yes! Private personas were NOT uploaded');
// Step 6: User wants to share the URL
console.log('\n 6️⃣ User action: "Share the GitHub URL with friends"');
const urlAccessible = await githubClient.verifyUrl(uploadUrl);
expect(urlAccessible).toBe(true);
console.log(` ✅ URL is shareable and working: ${uploadUrl}`);
console.log('\n🎉 Complete user flow test PASSED!');
console.log(' The exact scenario from the QA report works correctly.\n');
}, 90000);
});
});