diff --git a/apps/web-client/src/services/api/__tests__/api-keys.api.test.ts b/apps/web-client/src/services/api/__tests__/api-keys.api.test.ts new file mode 100644 index 00000000..7fa10a83 --- /dev/null +++ b/apps/web-client/src/services/api/__tests__/api-keys.api.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { apiKeysApi } from '../api-keys.api'; +import { userApi } from '../user.api'; + +vi.mock('../user.api', () => ({ + userApi: { + getProfile: vi.fn(), + setProfileAttribute: vi.fn(), + }, +})); + +const mockedUserApi = vi.mocked(userApi); + +describe('apiKeysApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.stubGlobal('crypto', { + getRandomValues: (buffer: Uint8Array) => { + buffer.fill(1); + return buffer; + }, + randomUUID: () => 'uuid-123', + subtle: { + digest: vi.fn(async () => new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer), + }, + }); + }); + + it('lists active API keys and filters revoked records', async () => { + mockedUserApi.getProfile.mockResolvedValue({ + success: true, + data: { + attributes: [ + { + key: 'api_key_active', + value: JSON.stringify({ + id: 'active', + name: 'Active Key', + keyPrefix: 'gg_active', + keySuffix: '1234', + keyHash: 'hash', + createdAt: '2026-01-01T00:00:00.000Z', + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + }), + valueType: 'Json', + }, + { + key: 'api_key_revoked', + value: JSON.stringify({ + id: 'revoked', + name: 'Revoked Key', + keyPrefix: 'gg_revoked', + keySuffix: '5678', + keyHash: 'hash', + createdAt: '2026-01-02T00:00:00.000Z', + lastUsedAt: null, + expiresAt: null, + revokedAt: '2026-01-03T00:00:00.000Z', + }), + valueType: 'Json', + }, + ], + }, + timestamp: new Date().toISOString(), + } as never); + + const response = await apiKeysApi.list('user-1'); + + expect(response.success).toBe(true); + expect(response.data).toHaveLength(1); + expect(response.data?.[0].id).toBe('active'); + }); + + it('creates API key and persists description and key record', async () => { + mockedUserApi.setProfileAttribute.mockResolvedValue({ + success: true, + data: { key: 'any', value: 'any', valueType: 'String' }, + timestamp: new Date().toISOString(), + } as never); + + const response = await apiKeysApi.create('user-1', { + name: 'Primary key', + description: 'Main key for integrations', + }); + + expect(response.success).toBe(true); + expect(response.data?.key.startsWith('gg_')).toBe(true); + expect(response.data?.apiKey.id).toBe('uuid-123'); + expect(mockedUserApi.setProfileAttribute).toHaveBeenCalledTimes(2); + expect(mockedUserApi.setProfileAttribute).toHaveBeenNthCalledWith( + 1, + 'user-1', + 'api_key_desc_uuid-123', + expect.objectContaining({ value: 'Main key for integrations' }) + ); + expect(mockedUserApi.setProfileAttribute).toHaveBeenNthCalledWith( + 2, + 'user-1', + 'api_key_uuid-123', + expect.objectContaining({ valueType: 'Json' }) + ); + }); + + it('revokes an existing API key record', async () => { + mockedUserApi.getProfile.mockResolvedValue({ + success: true, + data: { + attributes: [ + { + key: 'api_key_target', + value: JSON.stringify({ + id: 'target', + name: 'Target Key', + keyPrefix: 'gg_target', + keySuffix: '9999', + keyHash: 'hash', + createdAt: '2026-01-01T00:00:00.000Z', + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + }), + valueType: 'Json', + }, + ], + }, + timestamp: new Date().toISOString(), + } as never); + + mockedUserApi.setProfileAttribute.mockResolvedValue({ + success: true, + data: { key: 'api_key_target', value: 'revoked', valueType: 'Json' }, + timestamp: new Date().toISOString(), + } as never); + + const response = await apiKeysApi.delete('user-1', 'target'); + + expect(response.success).toBe(true); + expect(mockedUserApi.setProfileAttribute).toHaveBeenCalledTimes(1); + expect(mockedUserApi.setProfileAttribute).toHaveBeenCalledWith( + 'user-1', + 'api_key_target', + expect.objectContaining({ valueType: 'Json' }) + ); + }); +}); diff --git a/apps/web-client/src/services/api/__tests__/storage.api.test.ts b/apps/web-client/src/services/api/__tests__/storage.api.test.ts new file mode 100644 index 00000000..4aa041f0 --- /dev/null +++ b/apps/web-client/src/services/api/__tests__/storage.api.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { storageApi } from '../storage.api'; +import { apiClient } from '../client'; + +vi.mock('../client', () => ({ + apiClient: { + post: vi.fn(), + get: vi.fn(), + }, +})); + +const mockedApiClient = vi.mocked(apiClient); + +describe('storageApi.uploadAvatar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uploads avatar and resolves CDN URL', async () => { + mockedApiClient.post.mockResolvedValue({ + success: true, + data: { + success: true, + fileId: 'file-1', + objectKey: 'avatars/file-1.png', + }, + timestamp: new Date().toISOString(), + } as never); + + mockedApiClient.get.mockResolvedValue({ + success: true, + data: { + url: 'https://cdn.goodgo.dev/avatars/file-1.png', + isCDN: true, + description: 'cdn', + }, + timestamp: new Date().toISOString(), + } as never); + + const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + const response = await storageApi.uploadAvatar(file); + + expect(response.success).toBe(true); + expect(response.data?.fileId).toBe('file-1'); + expect(response.data?.url).toBe('https://cdn.goodgo.dev/avatars/file-1.png'); + }); + + it('falls back to empty URL when CDN lookup fails', async () => { + mockedApiClient.post.mockResolvedValue({ + success: true, + data: { + success: true, + fileId: 'file-2', + objectKey: 'avatars/file-2.png', + }, + timestamp: new Date().toISOString(), + } as never); + + mockedApiClient.get.mockRejectedValue(new Error('cdn lookup failed')); + + const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + const response = await storageApi.uploadAvatar(file); + + expect(response.success).toBe(true); + expect(response.data?.fileId).toBe('file-2'); + expect(response.data?.url).toBe(''); + }); + + it('throws when upload API returns unsuccessful payload', async () => { + mockedApiClient.post.mockResolvedValue({ + success: false, + data: { + success: false, + error: 'Upload failed', + }, + error: { + message: 'Upload failed', + code: 'UPLOAD_FAILED', + }, + timestamp: new Date().toISOString(), + } as never); + + const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + + await expect(storageApi.uploadAvatar(file)).rejects.toThrow('Upload failed'); + }); +}); diff --git a/apps/web-client/vitest.config.ts b/apps/web-client/vitest.config.ts index 8fa96a03..554e61ef 100644 --- a/apps/web-client/vitest.config.ts +++ b/apps/web-client/vitest.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - include: ['src/stores/__tests__/**/*.test.ts'], + include: [ + 'src/stores/__tests__/**/*.test.ts', + 'src/services/api/__tests__/**/*.test.ts', + ], exclude: ['e2e/**', 'src/**/__tests__/**/*.integration.test.tsx'], coverage: { provider: 'v8',