diff --git a/packages/app/control/src/app/api/v1/user/referral/route.ts b/packages/app/control/src/app/api/v1/user/referral/route.ts index e11cd6898..452b1ad1a 100644 --- a/packages/app/control/src/app/api/v1/user/referral/route.ts +++ b/packages/app/control/src/app/api/v1/user/referral/route.ts @@ -3,12 +3,54 @@ import { z } from 'zod'; import { appIdSchema } from '@/services/db/apps/lib/schemas'; import { authRoute } from '../../../../../lib/api/auth-route'; import { setAppMembershipReferrer } from '@/services/db/apps/membership'; +import { + getUserAppReferralCode, + createAppReferralCode, +} from '@/services/db/apps/referral-code'; + +const getUserReferralCodeSchema = z.object({ + echoAppId: appIdSchema, +}); const setUserReferrerForAppSchema = z.object({ echoAppId: appIdSchema, code: z.string(), }); +export const GET = authRoute + .query(getUserReferralCodeSchema) + .handler(async (_, context) => { + const { echoAppId } = context.query; + const userId = context.ctx.userId; + + let referralCode = await getUserAppReferralCode(userId, echoAppId); + + if (!referralCode) { + referralCode = await createAppReferralCode(userId, { + appId: echoAppId, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + if (!referralCode) { + return NextResponse.json( + { + success: false, + message: 'Failed to create referral code', + }, + { status: 500 } + ); + } + } + + return NextResponse.json({ + success: true, + message: 'Referral code retrieved successfully', + code: referralCode.code, + referralLinkUrl: referralCode.referralLinkUrl, + expiresAt: referralCode.expiresAt, + }); + }); + export const POST = authRoute .body(setUserReferrerForAppSchema) .handler(async (_, context) => { diff --git a/packages/app/control/src/services/db/apps/membership.ts b/packages/app/control/src/services/db/apps/membership.ts index bd88d7985..192c19928 100644 --- a/packages/app/control/src/services/db/apps/membership.ts +++ b/packages/app/control/src/services/db/apps/membership.ts @@ -163,21 +163,30 @@ export async function setAppMembershipReferrer( echoAppId: string, code: string ): Promise { - const appMembership = await db.appMembership.findUnique({ + // Get or create the app membership + const appMembership = await db.appMembership.upsert({ where: { userId_echoAppId: { userId, echoAppId, }, - referrerId: null, }, + create: { + userId, + echoAppId, + role: AppRole.CUSTOMER, + status: MembershipStatus.ACTIVE, + totalSpent: 0, + }, + update: {}, }); - if (appMembership) { - // If the user already has a referrer, return false + // Check if user already has a referrer + if (appMembership.referrerId) { return false; } + // Validate the referral code exists const referralCode = await db.referralCode.findUnique({ where: { code, diff --git a/packages/tests/integration/README.md b/packages/tests/integration/README.md index e5e0c8418..ad47c140c 100644 --- a/packages/tests/integration/README.md +++ b/packages/tests/integration/README.md @@ -1 +1,82 @@ -# Integration test trigger +# Integration Tests + +This package contains integration tests for the Echo platform. + +## Setup + +1. Set up the test environment: +```bash +pnpm env:setup +``` + +2. Seed the test database: +```bash +pnpm db:seed +``` + +## Running Tests + +Run all integration tests: +```bash +pnpm test:watch +``` + +Run specific test suites: +```bash +# Echo Data Server tests +pnpm test:echo-data-server + +# OAuth Protocol tests +pnpm test:oauth-protocol +``` + +## Test Suites + +### Echo Data Server Tests +Located in `tests/echo-data-server/`: +- `api-key.client.test.ts` - API key authentication and usage +- `402-auth.client.test.ts` - Payment required (402) authentication flow +- `echo-access-jwt.client.test.ts` - JWT token validation +- `free-tier.client.test.ts` - Free tier functionality +- `referral-code.client.test.ts` - Referral code creation and application +- `in-flight-requests.test.ts` - Concurrent request handling + +### OAuth Protocol Tests +Located in `tests/oauth-protocol/`: +- OAuth authorization flow +- Token refresh and lifecycle +- PKCE security +- CSRF vulnerability testing + +## Referral Code Tests + +The referral code integration tests (`referral-code.client.test.ts`) cover: + +1. **GET endpoint** - Retrieval of referral codes: + - Retrieving an existing referral code for a user + - Auto-creating a referral code for users who don't have one + - Ensuring consistency across multiple requests + +2. **POST endpoint** - Application of referral codes: + - Successfully applying another user's referral code + - Rejecting invalid referral codes + - Preventing users from applying codes when they already have a referrer + +### Test Data + +Referral code test data is defined in `config/test-data.ts`: +- Primary user has referral code: `TEST-REFERRAL-CODE-PRIMARY` +- Secondary user has referral code: `TEST-REFERRAL-CODE-SECONDARY` +- Tertiary user has no referral code (created during tests) + +## Database Management + +Reset and reseed the database: +```bash +pnpm db:reset-and-seed +``` + +Reset only: +```bash +pnpm db:reset +``` diff --git a/packages/tests/integration/config/test-data.ts b/packages/tests/integration/config/test-data.ts index c80b92ec0..7ef2dbae0 100644 --- a/packages/tests/integration/config/test-data.ts +++ b/packages/tests/integration/config/test-data.ts @@ -131,6 +131,26 @@ export const TEST_DATA = { }, }, + // Referral code configurations + referralCodes: { + primaryUserCode: { + id: '88888888-8888-4888-8888-888888888888', + code: 'TEST-REFERRAL-CODE-PRIMARY', + userId: '11111111-1111-4111-8111-111111111111', // Primary test user + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now + isArchived: false, + usedAt: null, + }, + secondaryUserCode: { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + code: 'TEST-REFERRAL-CODE-SECONDARY', + userId: '33333333-3333-4333-8333-333333333333', // Secondary test user + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now + isArchived: false, + usedAt: null, + }, + }, + // Test timeouts and delays timeouts: { default: 30000, @@ -234,6 +254,16 @@ export const TEST_SPEND_POOL_IDS = { primary: TEST_DATA.spendPools.primary.id, }; +export const TEST_REFERRAL_CODE_IDS = { + primary: TEST_DATA.referralCodes.primaryUserCode.id, + secondary: TEST_DATA.referralCodes.secondaryUserCode.id, +}; + +export const TEST_REFERRAL_CODES = { + primary: TEST_DATA.referralCodes.primaryUserCode.code, + secondary: TEST_DATA.referralCodes.secondaryUserCode.code, +}; + // Type definitions for test data export type TestData = typeof TEST_DATA; export type TestUser = typeof TEST_DATA.users.primary; @@ -242,3 +272,4 @@ export type TestApiKey = typeof TEST_DATA.apiKeys.primary; export type TestSpendPool = typeof TEST_DATA.spendPools.primary; export type TestUserSpendPoolUsage = typeof TEST_DATA.userSpendPoolUsage.tertiaryUserPrimaryPool; +export type TestReferralCode = typeof TEST_DATA.referralCodes.primaryUserCode; diff --git a/packages/tests/integration/scripts/seed-integration-db.ts b/packages/tests/integration/scripts/seed-integration-db.ts index 161d3e42d..86555b85a 100644 --- a/packages/tests/integration/scripts/seed-integration-db.ts +++ b/packages/tests/integration/scripts/seed-integration-db.ts @@ -25,6 +25,7 @@ export async function seedIntegrationDatabase() { await prisma.spendPool.deleteMany(); await prisma.apiKey.deleteMany(); await prisma.appMembership.deleteMany(); + await prisma.referralCode.deleteMany(); await prisma.echoApp.deleteMany(); await prisma.user.deleteMany(); @@ -171,6 +172,17 @@ export async function seedIntegrationDatabase() { console.log('šŸ¤– Created test LLM transaction'); + // Create test referral codes + await prisma.referralCode.create({ + data: TEST_DATA.referralCodes.primaryUserCode, + }); + + await prisma.referralCode.create({ + data: TEST_DATA.referralCodes.secondaryUserCode, + }); + + console.log('šŸŽŸļø Created test referral codes'); + console.log('āœ… Integration test database seeded successfully'); console.log('\nšŸ“Š Summary:'); console.log(` - Users: 3`); @@ -181,6 +193,7 @@ export async function seedIntegrationDatabase() { console.log(` - User Spend Pool Usage: 1`); console.log(` - Payments: 1`); console.log(` - LLM Transactions: 1`); + console.log(` - Referral Codes: 2`); } catch (error) { console.error('āŒ Error seeding integration test database:', error); throw error; diff --git a/packages/tests/integration/tests/echo-data-server/referral-code.client.test.ts b/packages/tests/integration/tests/echo-data-server/referral-code.client.test.ts new file mode 100644 index 000000000..6f162daca --- /dev/null +++ b/packages/tests/integration/tests/echo-data-server/referral-code.client.test.ts @@ -0,0 +1,219 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import { TEST_CONFIG, TEST_DATA, TEST_CLIENT_IDS } from '@/config/index'; +import { echoControlApi, EchoControlApiClient } from '@/utils/api-client'; +import { + generateCodeVerifier, + generateCodeChallenge, + generateState, +} from '@/utils/auth-helpers'; + +describe('Referral Code Client', () => { + let primaryUserAccessToken: string; + let secondaryUserAccessToken: string; + let tertiaryUserAccessToken: string; + const testAppId = TEST_CLIENT_IDS.primary; + + beforeAll(async () => { + // Get access tokens via OAuth for all three test users + // Primary user (has a referral code in seed data) + const codeVerifier1 = generateCodeVerifier(); + const codeChallenge1 = generateCodeChallenge(codeVerifier1); + const state1 = generateState(); + + const primaryAuthUrl = await echoControlApi.validateOAuthAuthorizeRequest({ + client_id: testAppId, + redirect_uri: 'http://localhost:3000/callback', + state: state1, + code_challenge: codeChallenge1, + code_challenge_method: 'S256', + scope: 'llm:invoke offline_access', + prompt: 'none', + }); + + const primaryCallbackUrl = new URL(primaryAuthUrl); + const primaryAuthCode = primaryCallbackUrl.searchParams.get('code'); + expect(primaryAuthCode).toBeTruthy(); + + const primaryTokens = await echoControlApi.exchangeCodeForToken({ + code: primaryAuthCode!, + client_id: testAppId, + redirect_uri: 'http://localhost:3000/callback', + code_verifier: codeVerifier1, + }); + primaryUserAccessToken = primaryTokens.access_token; + + // Secondary user (also has a referral code in seed data) + const api2 = new EchoControlApiClient( + TEST_CONFIG.services.echoControl, + 'test-user-2' + ); + const codeVerifier2 = generateCodeVerifier(); + const codeChallenge2 = generateCodeChallenge(codeVerifier2); + const state2 = generateState(); + + const secondaryAuthUrl = await api2.validateOAuthAuthorizeRequest({ + client_id: testAppId, + redirect_uri: 'http://localhost:3000/callback', + state: state2, + code_challenge: codeChallenge2, + code_challenge_method: 'S256', + scope: 'llm:invoke offline_access', + prompt: 'none', + }); + + const secondaryCallbackUrl = new URL(secondaryAuthUrl); + const secondaryAuthCode = secondaryCallbackUrl.searchParams.get('code'); + expect(secondaryAuthCode).toBeTruthy(); + + const secondaryTokens = await echoControlApi.exchangeCodeForToken({ + code: secondaryAuthCode!, + client_id: testAppId, + redirect_uri: 'http://localhost:3000/callback', + code_verifier: codeVerifier2, + }); + secondaryUserAccessToken = secondaryTokens.access_token; + + // Tertiary user (does not have a referral code yet) + const api3 = new EchoControlApiClient( + TEST_CONFIG.services.echoControl, + 'test-user-3' + ); + const codeVerifier3 = generateCodeVerifier(); + const codeChallenge3 = generateCodeChallenge(codeVerifier3); + const state3 = generateState(); + + const tertiaryAuthUrl = await api3.validateOAuthAuthorizeRequest({ + client_id: testAppId, + redirect_uri: 'http://localhost:3000/callback', + state: state3, + code_challenge: codeChallenge3, + code_challenge_method: 'S256', + scope: 'llm:invoke offline_access', + prompt: 'none', + }); + + const tertiaryCallbackUrl = new URL(tertiaryAuthUrl); + const tertiaryAuthCode = tertiaryCallbackUrl.searchParams.get('code'); + expect(tertiaryAuthCode).toBeTruthy(); + + const tertiaryTokens = await echoControlApi.exchangeCodeForToken({ + code: tertiaryAuthCode!, + client_id: testAppId, + redirect_uri: 'http://localhost:3000/callback', + code_verifier: codeVerifier3, + }); + tertiaryUserAccessToken = tertiaryTokens.access_token; + }); + + test('should retrieve existing referral code for primary user', async () => { + const result = await echoControlApi.getUserReferralCode( + primaryUserAccessToken, + testAppId + ); + + expect(result.success).toBe(true); + expect(result.code).toBeDefined(); + expect(result.code).toBe(TEST_DATA.referralCodes.primaryUserCode.code); + expect(result.referralLinkUrl).toBeDefined(); + expect(result.referralLinkUrl).toContain(result.code); + expect(result.expiresAt).toBeDefined(); + + console.log('āœ… Primary user successfully retrieved their referral code'); + }); + + test('should create referral code for tertiary user who does not have one', async () => { + const result = await echoControlApi.getUserReferralCode( + tertiaryUserAccessToken, + testAppId + ); + + expect(result.success).toBe(true); + expect(result.code).toBeDefined(); + expect(result.referralLinkUrl).toBeDefined(); + expect(result.referralLinkUrl).toContain(result.code); + expect(result.expiresAt).toBeDefined(); + + console.log( + 'āœ… Tertiary user successfully created a new referral code', + result.code + ); + }); + + test('should apply referral code from primary user to secondary user membership', async () => { + const primaryReferralCode = await echoControlApi.getUserReferralCode( + primaryUserAccessToken, + testAppId + ); + + expect(primaryReferralCode.code).toBeDefined(); + + // Secondary user applies the primary user's referral code + const result = await echoControlApi.applyReferralCode( + secondaryUserAccessToken, + testAppId, + primaryReferralCode.code! + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('successfully'); + + console.log( + 'āœ… Secondary user successfully applied primary user referral code' + ); + }); + + test('should reject applying referral code when user already has a referrer', async () => { + const tertiaryReferralCode = await echoControlApi.getUserReferralCode( + tertiaryUserAccessToken, + testAppId + ); + + expect(tertiaryReferralCode.code).toBeDefined(); + + // Secondary user already has a referrer from previous test + // Attempt to apply tertiary user's code should fail + await expect( + echoControlApi.applyReferralCode( + secondaryUserAccessToken, + testAppId, + tertiaryReferralCode.code! + ) + ).rejects.toThrow(); + + console.log( + 'āœ… System correctly rejected referral code application for user who already has a referrer' + ); + }); + + test('should reject invalid referral code', async () => { + const invalidCode = 'INVALID-REFERRAL-CODE-12345'; + + await expect( + echoControlApi.applyReferralCode( + tertiaryUserAccessToken, + testAppId, + invalidCode + ) + ).rejects.toThrow(); + + console.log('āœ… System correctly rejected invalid referral code'); + }); + + test('should get same referral code on multiple requests', async () => { + const firstResult = await echoControlApi.getUserReferralCode( + primaryUserAccessToken, + testAppId + ); + + const secondResult = await echoControlApi.getUserReferralCode( + primaryUserAccessToken, + testAppId + ); + + expect(firstResult.code).toBe(secondResult.code); + expect(firstResult.referralLinkUrl).toBe(secondResult.referralLinkUrl); + + console.log('āœ… Referral code is consistent across multiple requests'); + }); +}); + diff --git a/packages/tests/integration/utils/api-client.ts b/packages/tests/integration/utils/api-client.ts index a07e6f7d2..470c23131 100644 --- a/packages/tests/integration/utils/api-client.ts +++ b/packages/tests/integration/utils/api-client.ts @@ -258,6 +258,67 @@ export class EchoControlApiClient { }); return await echoClient.balance.getFreeBalance(echoAppId); } + + // Referral code endpoints + async getUserReferralCode( + authToken: string, + echoAppId: string + ): Promise<{ + success: boolean; + message: string; + code?: string; + referralLinkUrl?: string; + expiresAt?: string; + }> { + const url = `${this.baseUrl}/api/v1/user/referral?echoAppId=${echoAppId}`; + const response = await this.fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to get referral code: ${response.status} ${response.statusText}\n${errorText}` + ); + } + + return response.json(); + } + + async applyReferralCode( + authToken: string, + echoAppId: string, + code: string + ): Promise<{ + success: boolean; + message: string; + }> { + const url = `${this.baseUrl}/api/v1/user/referral`; + const response = await this.fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + echoAppId, + code, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to apply referral code: ${response.status} ${response.statusText}\n${errorText}` + ); + } + + return response.json(); + } } // Export a default instance with test configuration