portfolio-single-upload.qa.test.tsโข16.6 kB
/**
* QA Test: Portfolio Single Element Upload
*
* This test verifies that the submit_content tool correctly uploads a single
* element to the user's GitHub portfolio without syncing everything.
*
* Based on QA report: docs/QA/QA-version-1-6-5-save-to-github-portfolio-failure.md
*/
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import * as path from 'path';
import * as fs from 'fs/promises';
import { PortfolioRepoManager } from '../../../src/portfolio/PortfolioRepoManager.js';
// We'll test PortfolioRepoManager directly since that's where the fix is
describe('Portfolio Single Element Upload - GitHub API Response Fix', () => {
let portfolioManager: PortfolioRepoManager;
let originalFetch: typeof fetch;
beforeEach(() => {
portfolioManager = new PortfolioRepoManager();
originalFetch = (global as any).fetch;
});
afterEach(() => {
(global as any).fetch = originalFetch;
jest.clearAllMocks();
});
describe('GitHub API Response Handling', () => {
it('should handle response with commit.html_url (standard case)', async () => {
// Mock the token
portfolioManager.setToken('ghp_test_token');
// Mock fetch for GitHub API
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Mock get authenticated user (needed after Issue #913 fix)
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response);
// Mock checking if file exists (returns null for new file)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
json: async () => null
} as Response);
// Mock successful file creation with standard response
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({
content: {
path: 'personas/test.md',
html_url: 'https://github.com/test/portfolio/blob/main/personas/test.md'
},
commit: {
sha: 'abc123',
html_url: 'https://github.com/test/portfolio/commit/abc123'
}
})
} as Response);
// Create a test element
const testElement = {
id: 'test_element',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Test Persona',
description: 'Test',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => 'test content',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
// This should work and return the commit URL
const result = await portfolioManager.saveElement(testElement, true);
expect(result).toBe('https://github.com/test/portfolio/commit/abc123');
});
it('should handle response with null commit (the bug from QA report)', async () => {
portfolioManager.setToken('ghp_test_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Mock get authenticated user (needed after Issue #913 fix)
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response);
// Mock checking if file exists
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
json: async () => null
} as Response);
// Mock the problematic response that caused the original error
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({
content: {
path: 'personas/test.md',
html_url: 'https://github.com/test/portfolio/blob/main/personas/test.md'
},
commit: null // THIS is what caused the error!
})
} as Response);
const testElement = {
id: 'test_element',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Test Persona',
description: 'Test',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => 'test content',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
// With our fix, this should NOT throw and should use fallback
const result = await portfolioManager.saveElement(testElement, true);
// Should return the content URL as fallback
expect(result).toBe('https://github.com/test/portfolio/blob/main/personas/test.md');
});
it('should use fallback URL when no commit or content URLs available', async () => {
portfolioManager.setToken('ghp_test_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Mock get authenticated user (needed after Issue #913 fix)
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
json: async () => null
} as Response);
// Minimal response with no URLs
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({
content: {
path: 'personas/test.md'
// No html_url
}
// No commit at all
})
} as Response);
const testElement = {
id: 'test_element',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Test Persona',
description: 'Test',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => 'test content',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
const result = await portfolioManager.saveElement(testElement, true);
// Should generate URL from path using the username from element
expect(result).toBe('https://github.com/testuser/dollhouse-portfolio/blob/main/personas/test.md');
});
it('should use ultimate fallback when response has no useful data', async () => {
portfolioManager.setToken('ghp_test_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Mock get authenticated user (needed after Issue #913 fix)
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
json: async () => null
} as Response);
// Response with no useful data at all
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({
// Completely unexpected structure
someField: 'value'
})
} as Response);
const testElement = {
id: 'test_element',
type: 'skills' as any, // Different type for variety
version: '1.0.0',
metadata: {
name: 'Test Skill',
description: 'Test',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => 'test content',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
const result = await portfolioManager.saveElement(testElement, true);
// Should use ultimate fallback pointing to element type directory
expect(result).toBe('https://github.com/testuser/dollhouse-portfolio/tree/main/skills');
});
});
describe('Single Element Upload Behavior', () => {
it('should upload only one element, not trigger bulk sync', async () => {
portfolioManager.setToken('ghp_test_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Track all API calls
const apiCalls: string[] = [];
mockFetch.mockImplementation(async (url: RequestInfo | URL, options?: RequestInit) => {
const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : (url as Request).url;
apiCalls.push(`${options?.method || 'GET'} ${urlString}`);
// Mock get authenticated user (needed after Issue #913 fix)
if (urlString.includes('/user')) {
return {
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response;
}
if (options?.method === 'PUT') {
return {
ok: true,
status: 201,
json: async () => ({
content: {
path: 'personas/ziggy.md',
html_url: 'https://github.com/testuser/dollhouse-portfolio/blob/main/personas/ziggy.md'
},
commit: {
html_url: 'https://github.com/testuser/dollhouse-portfolio/commit/abc'
}
})
} as Response;
}
return { ok: false, status: 404, headers: new Headers(), json: async () => null } as Response;
});
const ziggyElement = {
id: 'ziggy_element',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Ziggy',
description: 'Quantum Leap AI',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => '# Ziggy\nYou are Ziggy from Quantum Leap.',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
// Upload single element
await portfolioManager.saveElement(ziggyElement, true);
// Verify only ONE PUT request (single upload)
const putRequests = apiCalls.filter(call => call.startsWith('PUT'));
expect(putRequests).toHaveLength(1);
expect(putRequests[0]).toContain('personas/ziggy.md');
// Verify we didn't scan for other elements (no bulk sync behavior)
// In bulk sync, we'd see multiple GET requests for different element types
const getRequests = apiCalls.filter(call => call.startsWith('GET'));
expect(getRequests.length).toBeLessThanOrEqual(2); // One for /user, one for checking if file exists
});
it('simulates real user flow: upload Ziggy persona to personal GitHub portfolio', async () => {
portfolioManager.setToken('ghp_test_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// User has multiple personas locally
const localPersonas = [
{ name: 'Ziggy', private: false },
{ name: 'Private Work Assistant', private: true },
{ name: 'Family Helper', private: true }
];
// Track what gets uploaded
const uploadedElements: string[] = [];
mockFetch.mockImplementation(async (url: RequestInfo | URL, options?: RequestInit) => {
const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : (url as Request).url;
// Mock get authenticated user (needed after Issue #913 fix)
if (urlString.includes('/user')) {
return {
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response;
}
if (options?.method === 'PUT') {
const body = JSON.parse(options.body as string);
const content = atob(body.content);
uploadedElements.push(content);
return {
ok: true,
status: 201,
json: async () => ({
content: {
path: 'personas/ziggy.md',
html_url: 'https://github.com/testuser/dollhouse-portfolio/blob/main/personas/ziggy.md'
},
commit: {
html_url: 'https://github.com/testuser/dollhouse-portfolio/commit/abc'
}
})
} as Response;
}
return { ok: false, status: 404, headers: new Headers(), json: async () => null } as Response;
});
// User action: Upload ONLY Ziggy
const ziggyElement = {
id: 'ziggy_quantum_leap',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Ziggy',
description: 'A matter-of-fact, snarky AI assistant persona based on Quantum Leap',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => `---
name: Ziggy
description: A matter-of-fact, snarky AI assistant persona based on Quantum Leap
---
# Ziggy - Quantum Leap Supercomputer Persona
You are Ziggy, a sophisticated hybrid supercomputer with a massive ego.`,
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
const result = await portfolioManager.saveElement(ziggyElement, true);
// Verify success
expect(result).toContain('github.com/testuser/dollhouse-portfolio');
// CRITICAL: Verify only Ziggy was uploaded
expect(uploadedElements).toHaveLength(1);
expect(uploadedElements[0]).toContain('Ziggy');
expect(uploadedElements[0]).toContain('Quantum Leap');
// Verify private personas were NOT uploaded
expect(uploadedElements[0]).not.toContain('Private Work');
expect(uploadedElements[0]).not.toContain('Family Helper');
});
});
describe('Error Code Reporting', () => {
it('should return PORTFOLIO_SYNC_001 for authentication errors', async () => {
portfolioManager.setToken('bad_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Mock 401 authentication error
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => ({
message: 'Bad credentials'
})
} as Response);
const testElement = {
id: 'test_element',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Test',
description: 'Test',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => 'test content',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
await expect(portfolioManager.saveElement(testElement, true))
.rejects
.toThrow('GitHub authentication failed');
});
it('should return PORTFOLIO_SYNC_006 for rate limit errors', async () => {
portfolioManager.setToken('ghp_test_token');
const mockFetch = jest.fn<typeof fetch>();
(global as any).fetch = mockFetch as any;
// Mock get authenticated user (needed after Issue #913 fix)
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ login: 'testuser' })
} as Response);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
json: async () => null
} as Response);
// Mock 403 rate limit error
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => ({
message: 'API rate limit exceeded'
})
} as Response);
const testElement = {
id: 'test_element',
type: 'personas' as any,
version: '1.0.0',
metadata: {
name: 'Test',
description: 'Test',
author: 'testuser'
},
validate: () => ({ valid: true, errors: [] }),
serialize: () => 'test content',
deserialize: (_data: string) => {},
getStatus: () => 'inactive' as any
};
await expect(portfolioManager.saveElement(testElement, true))
.rejects
.toThrow('[PORTFOLIO_SYNC_006]');
});
});
});