mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 22:09:39 +02:00
961 lines
29 KiB
TypeScript
961 lines
29 KiB
TypeScript
/**
|
|
* B2B Organization Journey E2E Tests
|
|
*
|
|
* Complete end-to-end test for B2B workflows:
|
|
* 1. Register organization with owner
|
|
* 2. Verify organization credit balance initialized
|
|
* 3. Invite employees (simulated via direct DB for now)
|
|
* 4. Allocate credits to employees
|
|
* 5. Employee uses allocated credits with org tracking
|
|
* 6. Track organization-wide usage
|
|
* 7. Multi-org switching (future)
|
|
*
|
|
* NOTE: Organization registration via Better Auth is not yet fully integrated.
|
|
* For now, we simulate organization creation by directly inserting into the database.
|
|
* These tests will be updated when Better Auth organization plugin is fully integrated.
|
|
*/
|
|
|
|
import { Test } from '@nestjs/testing';
|
|
import type { TestingModule } from '@nestjs/testing';
|
|
import { INestApplication } from '@nestjs/common';
|
|
import request from 'supertest';
|
|
import { AppModule } from '../../src/app.module';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { getDb } from '../../src/db/connection';
|
|
import { organizations, members } from '../../src/db/schema';
|
|
import { randomBytes } from 'crypto';
|
|
|
|
// Helper to generate random IDs (avoiding nanoid ESM issues in Jest)
|
|
const generateId = (length = 16): string => {
|
|
return randomBytes(Math.ceil(length / 2))
|
|
.toString('hex')
|
|
.slice(0, length);
|
|
};
|
|
|
|
describe('B2B Organization Journey (E2E)', () => {
|
|
let app: INestApplication;
|
|
let ownerToken: string;
|
|
let employeeToken: string;
|
|
let employee2Token: string;
|
|
let organizationId: string;
|
|
let ownerId: string;
|
|
let employeeId: string;
|
|
let employee2Id: string;
|
|
let configService: ConfigService;
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
configService = app.get(ConfigService);
|
|
await app.init();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
describe('Phase 1: Organization Registration', () => {
|
|
const uniqueTimestamp = Date.now();
|
|
const ownerEmail = `b2b-owner-${uniqueTimestamp}@company.com`;
|
|
const ownerPassword = 'SecurePassword123!';
|
|
const organizationName = `Test Corp ${uniqueTimestamp}`;
|
|
|
|
it('should register organization owner user', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/auth/register')
|
|
.send({
|
|
email: ownerEmail,
|
|
password: ownerPassword,
|
|
name: 'John Owner',
|
|
})
|
|
.expect(201);
|
|
|
|
expect(response.body).toMatchObject({
|
|
id: expect.any(String),
|
|
email: ownerEmail,
|
|
name: 'John Owner',
|
|
});
|
|
|
|
ownerId = response.body.id;
|
|
});
|
|
|
|
it('should login as owner and receive tokens', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/auth/login')
|
|
.send({
|
|
email: ownerEmail,
|
|
password: ownerPassword,
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
user: {
|
|
id: ownerId,
|
|
email: ownerEmail,
|
|
},
|
|
accessToken: expect.any(String),
|
|
refreshToken: expect.any(String),
|
|
});
|
|
|
|
ownerToken = response.body.accessToken;
|
|
});
|
|
|
|
it('should create organization and add owner as member (simulated)', async () => {
|
|
// NOTE: This simulates what Better Auth organization plugin would do
|
|
// When Better Auth is integrated, this will be replaced with:
|
|
// POST /auth/register-b2b endpoint
|
|
|
|
const databaseUrl = configService.get<string>('database.url');
|
|
const db = getDb(databaseUrl!);
|
|
|
|
// Create organization
|
|
const orgId = generateId(16);
|
|
const slug = organizationName.toLowerCase().replace(/\s+/g, '-');
|
|
|
|
const [org] = await db
|
|
.insert(organizations)
|
|
.values({
|
|
id: orgId,
|
|
name: organizationName,
|
|
slug,
|
|
})
|
|
.returning();
|
|
|
|
organizationId = org.id;
|
|
|
|
// Add owner as member with 'owner' role
|
|
const [member] = await db
|
|
.insert(members)
|
|
.values({
|
|
id: generateId(16),
|
|
organizationId,
|
|
userId: ownerId,
|
|
role: 'owner',
|
|
})
|
|
.returning();
|
|
|
|
expect(org).toMatchObject({
|
|
id: organizationId,
|
|
name: organizationName,
|
|
slug,
|
|
});
|
|
|
|
expect(member).toMatchObject({
|
|
organizationId,
|
|
userId: ownerId,
|
|
role: 'owner',
|
|
});
|
|
});
|
|
|
|
it('should verify organization credit balance is initialized', async () => {
|
|
const databaseUrl = configService.get<string>('database.url');
|
|
const db = getDb(databaseUrl!);
|
|
|
|
// Manually initialize org balance (would be automatic with Better Auth)
|
|
const { createOrganizationCreditBalance } = await import(
|
|
'../../src/credits/credits.service'
|
|
).then((module) => {
|
|
const CreditsService = module.CreditsService;
|
|
const service = new CreditsService(configService);
|
|
return {
|
|
createOrganizationCreditBalance: (orgId: string) =>
|
|
service['createOrganizationCreditBalance'](orgId),
|
|
};
|
|
});
|
|
|
|
await createOrganizationCreditBalance(organizationId);
|
|
|
|
// Verify organization balance
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
balance: 0,
|
|
allocatedCredits: 0,
|
|
availableCredits: 0,
|
|
totalPurchased: 0,
|
|
totalAllocated: 0,
|
|
});
|
|
});
|
|
|
|
it('should verify owner has personal credit balance', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
balance: 0,
|
|
freeCreditsRemaining: 150, // Signup bonus
|
|
totalSpent: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Phase 2: Employee Onboarding', () => {
|
|
const employeeEmail = `b2b-employee-${Date.now()}@company.com`;
|
|
const employee2Email = `b2b-employee2-${Date.now()}@company.com`;
|
|
const employeePassword = 'SecurePassword123!';
|
|
|
|
it('should register first employee user', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/auth/register')
|
|
.send({
|
|
email: employeeEmail,
|
|
password: employeePassword,
|
|
name: 'Jane Employee',
|
|
})
|
|
.expect(201);
|
|
|
|
expect(response.body.email).toBe(employeeEmail);
|
|
employeeId = response.body.id;
|
|
});
|
|
|
|
it('should login as employee', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/auth/login')
|
|
.send({
|
|
email: employeeEmail,
|
|
password: employeePassword,
|
|
})
|
|
.expect(200);
|
|
|
|
employeeToken = response.body.accessToken;
|
|
});
|
|
|
|
it('should add employee to organization (simulated invitation acceptance)', async () => {
|
|
// NOTE: This simulates what Better Auth organization plugin would do
|
|
// When Better Auth is integrated, this will be:
|
|
// 1. POST /auth/organization/invite (by owner)
|
|
// 2. POST /auth/organization/accept-invitation (by employee)
|
|
|
|
const databaseUrl = configService.get<string>('database.url');
|
|
const db = getDb(databaseUrl!);
|
|
|
|
const [member] = await db
|
|
.insert(members)
|
|
.values({
|
|
id: generateId(16),
|
|
organizationId,
|
|
userId: employeeId,
|
|
role: 'member',
|
|
})
|
|
.returning();
|
|
|
|
expect(member).toMatchObject({
|
|
organizationId,
|
|
userId: employeeId,
|
|
role: 'member',
|
|
});
|
|
});
|
|
|
|
it('should register second employee user', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/auth/register')
|
|
.send({
|
|
email: employee2Email,
|
|
password: employeePassword,
|
|
name: 'Bob Employee',
|
|
})
|
|
.expect(201);
|
|
|
|
employee2Id = response.body.id;
|
|
});
|
|
|
|
it('should login as second employee', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/auth/login')
|
|
.send({
|
|
email: employee2Email,
|
|
password: employeePassword,
|
|
})
|
|
.expect(200);
|
|
|
|
employee2Token = response.body.accessToken;
|
|
});
|
|
|
|
it('should add second employee to organization', async () => {
|
|
const databaseUrl = configService.get<string>('database.url');
|
|
const db = getDb(databaseUrl!);
|
|
|
|
await db.insert(members).values({
|
|
id: generateId(16),
|
|
organizationId,
|
|
userId: employee2Id,
|
|
role: 'member',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Phase 3: Credit Allocation', () => {
|
|
it('should give organization some credits (simulated purchase)', async () => {
|
|
// Simulate organization purchasing 10,000 credits
|
|
const databaseUrl = configService.get<string>('database.url');
|
|
const db = getDb(databaseUrl!);
|
|
const { organizationBalances } = await import('../../src/db/schema');
|
|
const { eq } = await import('drizzle-orm');
|
|
|
|
await db
|
|
.update(organizationBalances)
|
|
.set({
|
|
balance: 10000,
|
|
totalPurchased: 10000,
|
|
availableCredits: 10000,
|
|
})
|
|
.where(eq(organizationBalances.organizationId, organizationId));
|
|
|
|
// Verify update
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.balance).toBe(10000);
|
|
expect(response.body.availableCredits).toBe(10000);
|
|
});
|
|
|
|
it('should allow owner to allocate credits to employee', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId,
|
|
amount: 500,
|
|
reason: 'Monthly allocation',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
allocation: {
|
|
organizationId,
|
|
employeeId,
|
|
amount: 500,
|
|
reason: 'Monthly allocation',
|
|
allocatedBy: ownerId,
|
|
},
|
|
organizationBalance: {
|
|
balance: 10000,
|
|
allocatedCredits: 500,
|
|
availableCredits: 9500,
|
|
},
|
|
employeeBalance: {
|
|
balance: 500,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should verify employee balance increased', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
balance: 500, // Allocated credits
|
|
freeCreditsRemaining: 150, // Still has signup bonus
|
|
});
|
|
});
|
|
|
|
it('should allow owner to allocate to second employee', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId: employee2Id,
|
|
amount: 300,
|
|
reason: 'Initial allocation',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.employeeBalance.balance).toBe(300);
|
|
});
|
|
|
|
it('should verify organization available credits reduced correctly', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
balance: 10000,
|
|
allocatedCredits: 800, // 500 + 300
|
|
availableCredits: 9200, // 10000 - 800
|
|
totalAllocated: 800,
|
|
});
|
|
});
|
|
|
|
it('should prevent non-owner from allocating credits', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId: employee2Id,
|
|
amount: 100,
|
|
reason: 'Unauthorized allocation attempt',
|
|
})
|
|
.expect(403);
|
|
|
|
expect(response.body.message).toContain('Only organization owners can allocate credits');
|
|
});
|
|
|
|
it('should prevent allocation exceeding available credits', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId,
|
|
amount: 10000, // More than available (9200)
|
|
reason: 'Exceeding available',
|
|
})
|
|
.expect(400);
|
|
|
|
expect(response.body.message).toContain('Insufficient organization credits');
|
|
});
|
|
|
|
it('should prevent negative credit allocation', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId,
|
|
amount: -100,
|
|
reason: 'Negative allocation',
|
|
})
|
|
.expect(400);
|
|
});
|
|
|
|
it('should show recent allocations in organization balance', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.recentAllocations).toBeDefined();
|
|
expect(Array.isArray(response.body.recentAllocations)).toBe(true);
|
|
expect(response.body.recentAllocations.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Most recent should be the second employee allocation
|
|
const mostRecent = response.body.recentAllocations[0];
|
|
expect(mostRecent).toMatchObject({
|
|
organizationId,
|
|
employeeId: employee2Id,
|
|
amount: 300,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Phase 4: Employee Credit Usage with Organization Tracking', () => {
|
|
it('should allow employee to use allocated credits with org tracking', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 50,
|
|
appId: 'chat',
|
|
description: 'AI chat conversation',
|
|
metadata: {
|
|
messageCount: 10,
|
|
},
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
transaction: {
|
|
userId: employeeId,
|
|
type: 'usage',
|
|
amount: -50,
|
|
appId: 'chat',
|
|
organizationId, // Critical: organization ID should be tracked
|
|
},
|
|
newBalance: {
|
|
balance: 450, // 500 - 50
|
|
freeCreditsRemaining: 150, // Unchanged (uses paid credits first)
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should verify transaction includes organization_id', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/credits/transactions')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
|
|
// Find the usage transaction we just made
|
|
const usageTransaction = response.body.find(
|
|
(t: any) => t.type === 'usage' && t.amount === -50
|
|
);
|
|
|
|
expect(usageTransaction).toBeDefined();
|
|
expect(usageTransaction.organizationId).toBe(organizationId);
|
|
expect(usageTransaction.appId).toBe('chat');
|
|
});
|
|
|
|
it('should allow second employee to use credits', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employee2Token}`)
|
|
.send({
|
|
amount: 75,
|
|
appId: 'picture',
|
|
description: 'Image generation',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.newBalance.balance).toBe(225); // 300 - 75
|
|
expect(response.body.transaction.organizationId).toBe(organizationId);
|
|
});
|
|
|
|
it('should use free credits before allocated credits', async () => {
|
|
// Employee currently has: 450 paid credits + 150 free credits
|
|
const response = await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 100,
|
|
appId: 'memoro',
|
|
description: 'Audio transcription',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.newBalance).toMatchObject({
|
|
balance: 450, // Unchanged (used free credits)
|
|
freeCreditsRemaining: 50, // 150 - 100
|
|
});
|
|
});
|
|
|
|
it('should handle using more than free credits', async () => {
|
|
// Employee now has: 450 paid + 50 free
|
|
const response = await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 200, // Will use all 50 free + 150 paid
|
|
appId: 'wisekeep',
|
|
description: 'Video analysis',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.newBalance).toMatchObject({
|
|
balance: 300, // 450 - 150
|
|
freeCreditsRemaining: 0, // All free credits used
|
|
});
|
|
});
|
|
|
|
it('should prevent employee from using more credits than available', async () => {
|
|
// Employee now has: 300 paid + 0 free = 300 total
|
|
await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 500, // More than available
|
|
appId: 'chat',
|
|
description: 'Should fail',
|
|
})
|
|
.expect(400);
|
|
});
|
|
|
|
it('should track all employee usage in transaction history', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/credits/transactions')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
|
|
// Filter to just usage transactions with org tracking
|
|
const orgUsage = response.body.filter(
|
|
(t: any) => t.type === 'usage' && t.organizationId === organizationId
|
|
);
|
|
|
|
expect(orgUsage.length).toBeGreaterThanOrEqual(4);
|
|
|
|
// All should have organizationId
|
|
orgUsage.forEach((transaction: any) => {
|
|
expect(transaction.organizationId).toBe(organizationId);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Phase 5: Organization Balance & Analytics', () => {
|
|
it('should show accurate organization balance after employee usage', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
// Organization balance should be unchanged (employees used their allocated credits)
|
|
expect(response.body).toMatchObject({
|
|
balance: 10000,
|
|
allocatedCredits: 800, // Still 800 allocated
|
|
availableCredits: 9200, // Still 9200 available
|
|
});
|
|
});
|
|
|
|
it('should allow additional allocation after usage', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId,
|
|
amount: 1000,
|
|
reason: 'Additional allocation after usage',
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.organizationBalance.allocatedCredits).toBe(1800); // 800 + 1000
|
|
expect(response.body.organizationBalance.availableCredits).toBe(8200); // 9200 - 1000
|
|
});
|
|
|
|
it('should verify employee received additional allocation', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.balance).toBe(1300); // 300 + 1000
|
|
});
|
|
|
|
it('should get employee balance within organization context', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/employee/${employeeId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toMatchObject({
|
|
balance: 1300,
|
|
freeCreditsRemaining: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Phase 6: Edge Cases & Security', () => {
|
|
it('should prevent allocating to non-existent employee', async () => {
|
|
const fakeEmployeeId = '00000000-0000-0000-0000-000000000000';
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId: fakeEmployeeId,
|
|
amount: 100,
|
|
reason: 'Allocation to non-existent user',
|
|
})
|
|
.expect(400); // Will fail when trying to create balance
|
|
});
|
|
|
|
it('should prevent using credits with wrong organization ID', async () => {
|
|
const fakeOrgId = 'fake-org-id-12345';
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/credits/organization/${fakeOrgId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 10,
|
|
appId: 'chat',
|
|
description: 'Wrong org usage',
|
|
})
|
|
.expect(200); // Currently succeeds but tracks wrong org ID
|
|
// TODO: Add validation to check user is member of organization
|
|
});
|
|
|
|
it('should handle concurrent allocation requests safely', async () => {
|
|
const requests = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
requests.push(
|
|
request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
employeeId: employee2Id,
|
|
amount: 100,
|
|
reason: `Concurrent allocation ${i}`,
|
|
})
|
|
);
|
|
}
|
|
|
|
const responses = await Promise.all(requests);
|
|
|
|
// All should either succeed or conflict
|
|
responses.forEach((response) => {
|
|
expect([200, 409]).toContain(response.status);
|
|
});
|
|
});
|
|
|
|
it('should validate allocation DTO', async () => {
|
|
// Missing required fields
|
|
await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({
|
|
organizationId,
|
|
// Missing employeeId and amount
|
|
})
|
|
.expect(400);
|
|
});
|
|
|
|
it('should require authentication for allocation endpoint', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/credits/organization/allocate')
|
|
.send({
|
|
organizationId,
|
|
employeeId,
|
|
amount: 100,
|
|
reason: 'No auth',
|
|
})
|
|
.expect(401);
|
|
});
|
|
|
|
it('should require authentication for org balance endpoint', async () => {
|
|
await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.expect(401);
|
|
});
|
|
});
|
|
|
|
describe('Phase 7: Transaction Idempotency', () => {
|
|
it('should support idempotent credit usage with org tracking', async () => {
|
|
const idempotencyKey = `org-idempotent-${Date.now()}`;
|
|
|
|
// First request
|
|
const response1 = await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 25,
|
|
appId: 'test',
|
|
description: 'Idempotency test with org',
|
|
idempotencyKey,
|
|
})
|
|
.expect(200);
|
|
|
|
const balanceAfterFirst = response1.body.newBalance.balance;
|
|
|
|
// Second request with same idempotency key
|
|
const response2 = await request(app.getHttpServer())
|
|
.post(`/credits/organization/${organizationId}/use`)
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.send({
|
|
amount: 25,
|
|
appId: 'test',
|
|
description: 'Idempotency test with org',
|
|
idempotencyKey,
|
|
})
|
|
.expect(200);
|
|
|
|
expect(response2.body.message).toBe('Transaction already processed');
|
|
|
|
// Verify balance unchanged
|
|
const balanceCheck = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
|
|
expect(balanceCheck.body.balance).toBe(balanceAfterFirst);
|
|
});
|
|
});
|
|
|
|
describe('Phase 8: Complete Organization Workflow', () => {
|
|
it('should demonstrate complete B2B flow summary', async () => {
|
|
// Get final organization balance
|
|
const orgBalance = await request(app.getHttpServer())
|
|
.get(`/credits/organization/${organizationId}/balance`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.expect(200);
|
|
|
|
// Get employee balances
|
|
const employee1Balance = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${employeeToken}`)
|
|
.expect(200);
|
|
|
|
const employee2Balance = await request(app.getHttpServer())
|
|
.get('/credits/balance')
|
|
.set('Authorization', `Bearer ${employee2Token}`)
|
|
.expect(200);
|
|
|
|
// Verify final state
|
|
expect(orgBalance.body.balance).toBe(10000); // Total purchased
|
|
expect(orgBalance.body.totalAllocated).toBeGreaterThan(0);
|
|
expect(orgBalance.body.availableCredits).toBeLessThan(10000);
|
|
|
|
expect(employee1Balance.body.balance).toBeGreaterThan(0);
|
|
expect(employee2Balance.body.balance).toBeGreaterThan(0);
|
|
|
|
// Log summary for visibility
|
|
console.log('\n=== B2B Journey Summary ===');
|
|
console.log('Organization Balance:', orgBalance.body);
|
|
console.log('Employee 1 Balance:', employee1Balance.body);
|
|
console.log('Employee 2 Balance:', employee2Balance.body);
|
|
console.log('===========================\n');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('B2B Organization Journey - Future Features', () => {
|
|
let app: INestApplication;
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
await app.init();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
describe('Multi-Organization Switching (Future)', () => {
|
|
it.skip('should allow user to belong to multiple organizations', async () => {
|
|
// Future: Test user with multiple org memberships
|
|
// 1. User is member of Org A and Org B
|
|
// 2. User can view all organizations they belong to
|
|
// 3. User has separate credit balances for each org
|
|
});
|
|
|
|
it.skip('should switch active organization and update JWT claims', async () => {
|
|
// Future: Test setActiveOrganization
|
|
// POST /auth/organization/set-active
|
|
// - Switch from Org A to Org B
|
|
// - JWT should update with new organization context
|
|
// - Credit operations should use new organization
|
|
});
|
|
|
|
it.skip('should include correct organization in JWT claims', async () => {
|
|
// Future: Verify JWT payload structure for B2B users
|
|
// JWT should contain:
|
|
// {
|
|
// sub: "user-123",
|
|
// email: "employee@acme.com",
|
|
// role: "user",
|
|
// customer_type: "b2b",
|
|
// organization: {
|
|
// id: "org-789",
|
|
// name: "Acme Corp",
|
|
// role: "member"
|
|
// },
|
|
// credit_balance: 500
|
|
// }
|
|
});
|
|
});
|
|
|
|
describe('Email Invitation Flow (Future)', () => {
|
|
it.skip('should send invitation email when owner invites employee', async () => {
|
|
// Future: Test email sending integration
|
|
// POST /auth/organization/invite
|
|
// - Email sent to employee@example.com
|
|
// - Email contains invitation link with token
|
|
// - Invitation expires after 7 days
|
|
});
|
|
|
|
it.skip('should allow employee to register via invitation link', async () => {
|
|
// Future: Test invitation acceptance
|
|
// GET /auth/invitation/{token}
|
|
// - Employee clicks link, creates account
|
|
// - Automatically added to organization
|
|
// - Personal balance initialized
|
|
});
|
|
|
|
it.skip('should handle invitation to existing user', async () => {
|
|
// Future: Test invitation to existing email
|
|
// - User already has account
|
|
// - Click invitation link -> auto-accept
|
|
// - Added to organization, no new account created
|
|
});
|
|
});
|
|
|
|
describe('Advanced Permission System (Future)', () => {
|
|
it.skip('should allow admins to invite but not allocate credits', async () => {
|
|
// Future: Test role-based permissions
|
|
// - Admin can POST /auth/organization/invite
|
|
// - Admin cannot POST /credits/organization/allocate
|
|
});
|
|
|
|
it.skip('should allow members to view but not manage', async () => {
|
|
// Future: Test member permissions
|
|
// - Member can GET /credits/organization/:id/balance
|
|
// - Member cannot POST /auth/organization/invite
|
|
// - Member cannot POST /credits/organization/allocate
|
|
});
|
|
|
|
it.skip('should prevent removed members from accessing organization', async () => {
|
|
// Future: Test member removal
|
|
// DELETE /auth/organization/members/{memberId}
|
|
// - Member can no longer access org resources
|
|
// - Member's allocated credits are revoked
|
|
// - Transaction history preserved
|
|
});
|
|
});
|
|
|
|
describe('Organization Purchase Flow (Future)', () => {
|
|
it.skip('should allow organization to purchase credits via Stripe', async () => {
|
|
// Future: Test B2B purchase flow
|
|
// POST /credits/organization/purchase
|
|
// - Organization owner purchases 10,000 credits
|
|
// - Stripe payment succeeds
|
|
// - Organization balance updated
|
|
// - Purchase recorded in history
|
|
});
|
|
|
|
it.skip('should handle failed organization purchases', async () => {
|
|
// Future: Test payment failure
|
|
// - Stripe payment fails
|
|
// - Organization balance unchanged
|
|
// - Purchase marked as failed
|
|
});
|
|
});
|
|
|
|
describe('Analytics & Reporting (Future)', () => {
|
|
it.skip('should provide organization-wide usage statistics', async () => {
|
|
// Future: Test analytics endpoint
|
|
// GET /credits/organization/:id/analytics?period=30d
|
|
// - Total credits used by all employees
|
|
// - Breakdown by app (chat, picture, memoro, etc.)
|
|
// - Breakdown by employee
|
|
// - Usage trends over time
|
|
});
|
|
|
|
it.skip('should export organization transaction history', async () => {
|
|
// Future: Test export functionality
|
|
// GET /credits/organization/:id/export?format=csv
|
|
// - Download CSV of all transactions
|
|
// - Include employee names, dates, apps, amounts
|
|
});
|
|
});
|
|
|
|
describe('Credit Reclamation (Future)', () => {
|
|
it.skip('should allow owner to reclaim unused credits from employee', async () => {
|
|
// Future: Test credit reclamation
|
|
// POST /credits/organization/reclaim
|
|
// - Owner takes back 200 credits from employee
|
|
// - Employee balance reduced
|
|
// - Organization available credits increased
|
|
// - Reclamation recorded in allocation history
|
|
});
|
|
|
|
it.skip('should prevent reclaiming more than employee has', async () => {
|
|
// Future: Validation test
|
|
// - Employee has 100 credits
|
|
// - Owner tries to reclaim 200 credits
|
|
// - Request fails with appropriate error
|
|
});
|
|
});
|
|
});
|