From 9ca535fe50ab5697dcd53df99deb91173b897d93 Mon Sep 17 00:00:00 2001 From: dkarasiewicz Date: Fri, 23 Jan 2026 20:49:04 +0100 Subject: [PATCH 1/5] LAU-27: add integration tests for auth and workspace resolvers --- .../core-e2e/src/core/auth.controller.spec.ts | 247 +++++++++++ apps/core-e2e/src/core/core.spec.ts | 10 - .../src/core/workspace.resolver.spec.ts | 401 ++++++++++++++++++ .../src/support/analytics.module.mock.ts | 18 + apps/core-e2e/src/support/auth.guard.mock.ts | 54 +++ .../src/support/common.module.mock.ts | 33 ++ .../src/support/config.module.mock.ts | 19 + apps/core-e2e/src/support/db.module.mock.ts | 33 ++ .../src/support/eventbus.module.mock.ts | 19 + apps/core-e2e/src/support/global-setup.ts | 8 +- apps/core-e2e/src/support/global-teardown.ts | 5 - .../core-e2e/src/support/redis.module.mock.ts | 19 + package.json | 1 + pnpm-lock.yaml | 94 ++++ 14 files changed, 939 insertions(+), 22 deletions(-) create mode 100644 apps/core-e2e/src/core/auth.controller.spec.ts delete mode 100644 apps/core-e2e/src/core/core.spec.ts create mode 100644 apps/core-e2e/src/core/workspace.resolver.spec.ts create mode 100644 apps/core-e2e/src/support/analytics.module.mock.ts create mode 100644 apps/core-e2e/src/support/auth.guard.mock.ts create mode 100644 apps/core-e2e/src/support/common.module.mock.ts create mode 100644 apps/core-e2e/src/support/config.module.mock.ts create mode 100644 apps/core-e2e/src/support/db.module.mock.ts create mode 100644 apps/core-e2e/src/support/eventbus.module.mock.ts create mode 100644 apps/core-e2e/src/support/redis.module.mock.ts diff --git a/apps/core-e2e/src/core/auth.controller.spec.ts b/apps/core-e2e/src/core/auth.controller.spec.ts new file mode 100644 index 0000000..5fe363c --- /dev/null +++ b/apps/core-e2e/src/core/auth.controller.spec.ts @@ -0,0 +1,247 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthModule, MainAuthGuard } from '@launchline/core-auth'; +import request from 'supertest'; +import { INestApplication, HttpStatus } from '@nestjs/common'; +import { MockConfigModule } from '../support/config.module.mock'; +import { MockCommonModule } from '../support/common.module.mock'; +import { DB_CONNECTION, user, otp } from '@launchline/core-common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq } from 'drizzle-orm'; +import { Pool } from 'pg'; +import { MockDbModule } from '../support/db.module.mock'; +import { MockRedisModule } from '../support/redis.module.mock'; +import { MockEventBusModule } from '../support/eventbus.module.mock'; +import { randomUUID } from 'node:crypto'; +import session from 'express-session'; +import passport from 'passport'; +import { MockMainAuthGuard } from '../support/auth.guard.mock'; +import { UserRole } from '@launchline/models'; +import { MockAnalyticsModule } from '../support/analytics.module.mock'; + +describe('AuthController (integration)', () => { + const emailForOtp = 'email-otp@example.com'; + let app: INestApplication; + let db: NodePgDatabase & { $client: Pool }; + let verifiedUser: typeof user.$inferSelect; + let unverifiedUser: typeof user.$inferSelect; + let otpCode: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + AuthModule, + MockDbModule, + MockRedisModule, + MockConfigModule, + MockCommonModule, + MockEventBusModule, + MockAnalyticsModule, + ], + }) + .overrideProvider(MainAuthGuard) + .useValue(MockMainAuthGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + }), + ); + app.use(passport.session()); + + db = app.get(DB_CONNECTION); + + await app.init(); + }); + + beforeEach(async () => { + otpCode = '123456'; + + // Create verified user + [verifiedUser] = await db + .insert(user) + .values({ + id: randomUUID(), + updatedAt: new Date(), + email: 'verified@example.com', + role: UserRole.WORKSPACE_MEMBER, + createdAt: new Date(), + isEmailVerified: true, + }) + .returning(); + + // Create unverified user for OTP verification flow + [unverifiedUser] = await db + .insert(user) + .values({ + id: randomUUID(), + updatedAt: new Date(), + email: 'unverified@example.com', + role: UserRole.WORKSPACE_MEMBER, + createdAt: new Date(), + isEmailVerified: false, + }) + .returning(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + + await db.$client.query('TRUNCATE TABLE "Otp" CASCADE;'); + await db.$client.query('TRUNCATE TABLE "User" CASCADE;'); + }); + + afterAll(async () => { + await db.$client.end(); + await app.close(); + }); + + describe('sendOtp', () => { + it('should send OTP successfully', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/send') + .send({ + email: unverifiedUser.email, + }); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body).toEqual({ success: true }); + + const otps = await db + .select() + .from(otp) + .where(eq(otp.identifier, unverifiedUser.email)); + + expect(otps).toHaveLength(1); + }); + + it('should not send OTP to a non-existent user', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/send') + .send({ + email: 'newuser@example.com', + }); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('verifyOtp', () => { + it('should verify OTP and update an unverified user', async () => { + await request(app.getHttpServer()).post('/auth/login/otp/send').send({ + email: unverifiedUser.email, + }); + + const otpAttempt = await db + .select() + .from(otp) + .where(eq(otp.identifier, unverifiedUser.email)) + .limit(1); + + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/verify') + .send({ + email: unverifiedUser.email, + code: otpAttempt[0].code, + }); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body).toHaveProperty('userId', unverifiedUser.id); + expect(response.body).toHaveProperty('role', UserRole.WORKSPACE_MEMBER); + + const [updatedUser] = await db + .select() + .from(user) + .where(eq(user.id, unverifiedUser.id)); + + expect(updatedUser.isEmailVerified).toBe(true); + }); + + it('should reject invalid OTP code', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/verify') + .send({ + email: unverifiedUser.email, + code: 'wrong-code', + }); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + it('should reject expired OTP', async () => { + const expiredEmail = 'expired@example.com'; + + await db.insert(user).values({ + id: randomUUID(), + email: expiredEmail, + role: UserRole.WORKSPACE_MEMBER, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await db.insert(otp).values({ + id: randomUUID(), + identifier: expiredEmail, + code: otpCode, + expiresAt: new Date(Date.now() - 1000), // Expired 1 second ago + updatedAt: new Date(), + } satisfies typeof otp.$inferInsert); + + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/verify') + .send({ + email: expiredEmail, + code: otpCode, + }); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + it('should return 401 when user does not exist', async () => { + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/verify') + .send({ + email: emailForOtp, + code: otpCode, + }); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + it('should return 401 when OTP does not exist for user', async () => { + const userWithoutOtp = 'no-otp@example.com'; + await db.insert(user).values({ + id: randomUUID(), + email: userWithoutOtp, + role: UserRole.WORKSPACE_MEMBER, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const response = await request(app.getHttpServer()) + .post('/auth/login/otp/verify') + .send({ + email: userWithoutOtp, + code: otpCode, + }); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('currentUser', () => { + it('should return the current authenticated user', async () => { + const response = await request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${verifiedUser.id}`); + + expect(response.status).toBe(HttpStatus.OK); + expect(response.body).toHaveProperty('userId', verifiedUser.id); + expect(response.body).toHaveProperty('role', UserRole.WORKSPACE_MEMBER); + }); + }); +}); diff --git a/apps/core-e2e/src/core/core.spec.ts b/apps/core-e2e/src/core/core.spec.ts deleted file mode 100644 index e8ac2a6..0000000 --- a/apps/core-e2e/src/core/core.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -describe('GET /api', () => { - it('should return a message', async () => { - const res = await axios.get(`/api`); - - expect(res.status).toBe(200); - expect(res.data).toEqual({ message: 'Hello API' }); - }); -}); diff --git a/apps/core-e2e/src/core/workspace.resolver.spec.ts b/apps/core-e2e/src/core/workspace.resolver.spec.ts new file mode 100644 index 0000000..58d176b --- /dev/null +++ b/apps/core-e2e/src/core/workspace.resolver.spec.ts @@ -0,0 +1,401 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { INestApplication } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { MockConfigModule } from '../support/config.module.mock'; +import { MockCommonModule } from '../support/common.module.mock'; +import { + DB_CONNECTION, + workspace, + workspaceMembership, + user, +} from '@launchline/core-common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { MockDbModule } from '../support/db.module.mock'; +import { MockRedisModule } from '../support/redis.module.mock'; +import { MockEventBusModule } from '../support/eventbus.module.mock'; +import { randomUUID } from 'node:crypto'; +import { MainAuthGuard } from '@launchline/core-auth'; +import { MockMainAuthGuard } from '../support/auth.guard.mock'; +import { + UserRole, + WorkspaceMemberRole, + WorkspaceMemberStatus, +} from '@launchline/models'; +import { MockAnalyticsModule } from '../support/analytics.module.mock'; +import { WorkspaceModule } from '@launchline/core-workspace'; +import { EventBusService } from '@launchline/core-common'; + +describe('WorkspaceResolver (integration)', () => { + let app: INestApplication; + let db: NodePgDatabase & { $client: Pool }; + let dbUser: typeof user.$inferSelect; + let dbWorkspace: typeof workspace.$inferSelect; + let dbMembership: typeof workspaceMembership.$inferSelect; + let eventBus: EventBusService; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + GraphQLModule.forRoot({ + driver: ApolloDriver, + autoSchemaFile: true, + subscriptions: { + 'graphql-ws': true, + }, + }), + WorkspaceModule, + MockCommonModule, + MockConfigModule, + MockDbModule, + MockRedisModule, + MockEventBusModule, + MockAnalyticsModule, + ], + }) + .overrideProvider(MainAuthGuard) + .useClass(MockMainAuthGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + db = app.get(DB_CONNECTION); + eventBus = app.get(EventBusService); + + await app.init(); + }); + + beforeEach(async () => { + [dbUser] = await db + .insert(user) + .values({ + id: randomUUID(), + updatedAt: new Date(), + email: 'workspace-admin@example.com', + role: UserRole.WORKSPACE_ADMIN, + createdAt: new Date(), + }) + .returning(); + + [dbWorkspace] = await db + .insert(workspace) + .values({ + id: randomUUID(), + name: 'Test Workspace', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + [dbMembership] = await db + .insert(workspaceMembership) + .values({ + id: randomUUID(), + createdAt: new Date(), + updatedAt: new Date(), + workspaceId: dbWorkspace.id, + userId: dbUser.id, + role: WorkspaceMemberRole.ADMIN, + status: WorkspaceMemberStatus.ACTIVE, + email: dbUser.email, + }) + .returning(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + + await db.$client.query('TRUNCATE TABLE "WorkspaceInvite" CASCADE;'); + await db.$client.query('TRUNCATE TABLE "WorkspaceMembership" CASCADE;'); + await db.$client.query('TRUNCATE TABLE "Workspace" CASCADE;'); + await db.$client.query('TRUNCATE TABLE "User" CASCADE;'); + }); + + afterAll(async () => { + await db.$client.end(); + await app.close(); + }); + + describe('getWorkspace', () => { + it("returns the authenticated user's workspace", async () => { + const query = ` + query Workspace { + getWorkspace { + id + name + } + } + `; + + const res = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.data.getWorkspace).toEqual({ + id: dbWorkspace.id, + name: dbWorkspace.name, + }); + }); + }); + + describe('members field', () => { + it('returns members for a workspace', async () => { + const query = ` + query WorkspaceWithMembers { + getWorkspace { + id + members { + id + userId + status + role + } + } + } + `; + + const res = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.data.getWorkspace.members).toEqual([ + { + id: dbMembership.id, + userId: dbUser.id, + status: WorkspaceMemberStatus.ACTIVE, + role: WorkspaceMemberRole.ADMIN, + }, + ]); + }); + }); + + describe('inviteWorkspaceMember', () => { + it('creates an invitation and returns token', async () => { + const mutation = ` + mutation InviteWorkspaceMember($input: CreateWorkspaceInvitationInput!) { + inviteWorkspaceMember(input: $input) + } + `; + + const variables = { + input: { + workspaceId: dbWorkspace.id, + role: WorkspaceMemberRole.MEMBER, + emailHint: 'invitee@example.com', + }, + }; + + const res = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ query: mutation, variables }); + + expect(res.status).toBe(200); + expect(res.body.data.inviteWorkspaceMember).toBeDefined(); + + const query = ` + query WorkspaceWithMembers { + getWorkspace { + id + members { + id + userId + status + role + } + } + } + `; + + const workspaceMembersRes = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ query }); + + expect(workspaceMembersRes.status).toBe(200); + + const members = workspaceMembersRes.body.data.getWorkspace.members; + const invitedMember = members.find( + (member: { + status: WorkspaceMemberStatus; + role: WorkspaceMemberRole; + }) => member.status === WorkspaceMemberStatus.INVITED, + ); + + expect(invitedMember).toBeDefined(); + expect(invitedMember.role).toBe(WorkspaceMemberRole.MEMBER); + }); + }); + + describe('getInvite', () => { + it('returns invitation by token', async () => { + const mutation = ` + mutation InviteWorkspaceMember($input: CreateWorkspaceInvitationInput!) { + inviteWorkspaceMember(input: $input) + } + `; + + const createInviteRes = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ + query: mutation, + variables: { + input: { + workspaceId: dbWorkspace.id, + role: WorkspaceMemberRole.MEMBER, + emailHint: 'invitee@example.com', + }, + }, + }); + + const query = ` + query GetInvite($input: GetWorkspaceInvitationInput!) { + getInvite(input: $input) { + token + workspaceId + workspaceName + role + emailHint + expiresAt + } + } + `; + + const variables = { + input: { token: createInviteRes.body.data.inviteWorkspaceMember }, + }; + + const res = await request(app.getHttpServer()) + .post('/graphql') + .send({ query, variables }); + + expect(res.status).toBe(200); + expect(res.body.data.getInvite.workspaceId).toBe(dbWorkspace.id); + expect(res.body.data.getInvite.workspaceName).toBe(dbWorkspace.name); + expect(res.body.data.getInvite.role).toBe(WorkspaceMemberRole.MEMBER); + expect(res.body.data.getInvite.emailHint).toBe('invitee@example.com'); + }); + }); + + describe('redeemInvite', () => { + it('redeems invite, activates membership and publishes event', async () => { + const createInviteRes = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ + query: ` + mutation InviteWorkspaceMember($input: CreateWorkspaceInvitationInput!) { + inviteWorkspaceMember(input: $input) + } + `, + variables: { + input: { + workspaceId: dbWorkspace.id, + role: WorkspaceMemberRole.MEMBER, + emailHint: 'invitee@example.com', + }, + }, + }); + + const publishSpy = jest.spyOn(eventBus, 'publish').mockResolvedValue(); + + const mutation = ` + mutation RedeemInvite($input: RedeemWorkspaceInvitationInput!) { + redeemInvite(input: $input) + } + `; + + const variables = { + input: { + token: createInviteRes.body.data.inviteWorkspaceMember, + email: 'invitee@example.com', + fullName: 'Invited User', + }, + }; + + const res = await request(app.getHttpServer()) + .post('/graphql') + .send({ query: mutation, variables }); + + expect(res.status).toBe(200); + expect(res.body.data.redeemInvite).toBe(true); + + const workspaceRes = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${dbUser.id} ${UserRole.WORKSPACE_ADMIN}`) + .send({ + query: ` + query WorkspaceWithMembers { + getWorkspace { + id + members { + id + userId + status + role + email + } + } + } + `, + }); + + const members = workspaceRes.body.data.getWorkspace.members; + const redeemedMember = members.find( + (member: { email: string }) => member.email === 'invitee@example.com', + ); + + expect(redeemedMember).toBeDefined(); + expect(redeemedMember.status).toBe(WorkspaceMemberStatus.ACTIVE); + + const query = ` + query GetInvite($input: GetWorkspaceInvitationInput!) { + getInvite(input: $input) { + token + workspaceId + workspaceName + role + emailHint + expiresAt + } + } + `; + + const redeemedInviteRes = await request(app.getHttpServer()) + .post('/graphql') + .send({ + query, + variables: { + input: { token: createInviteRes.body.data.inviteWorkspaceMember }, + }, + }); + + expect(redeemedInviteRes.status).toBe(200); + expect(redeemedInviteRes.body.data).toBeNull(); + + expect(publishSpy).toHaveBeenCalledWith({ + eventType: 'WORKSPACE_MEMBER_JOINED', + id: expect.any(String), + origin: 'WORKSPACE', + payload: { + email: 'invitee@example.com', + emittedAt: expect.any(String), + fullName: 'Invited User', + joinedAt: expect.any(String), + phoneNumber: undefined, + role: 'MEMBER', + workspaceId: dbWorkspace.id, + userId: expect.any(String), + }, + userId: undefined, + version: 'V1', + }); + }); + }); +}); diff --git a/apps/core-e2e/src/support/analytics.module.mock.ts b/apps/core-e2e/src/support/analytics.module.mock.ts new file mode 100644 index 0000000..a01cb5e --- /dev/null +++ b/apps/core-e2e/src/support/analytics.module.mock.ts @@ -0,0 +1,18 @@ +import { Global, Module } from '@nestjs/common'; + +import { ANALYTICS_CLIENT } from '@launchline/core-common'; + +@Global() +@Module({ + providers: [ + { + provide: ANALYTICS_CLIENT, + useValue: { + capture: jest.fn(), + alias: jest.fn(), + }, + }, + ], + exports: [ANALYTICS_CLIENT], +}) +export class MockAnalyticsModule {} diff --git a/apps/core-e2e/src/support/auth.guard.mock.ts b/apps/core-e2e/src/support/auth.guard.mock.ts new file mode 100644 index 0000000..6a0835d --- /dev/null +++ b/apps/core-e2e/src/support/auth.guard.mock.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ContextType, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { AuthenticatedUser } from '@launchline/core-common'; +import { UserRole } from '@launchline/models'; +import { MainAuthGuard } from '@launchline/core-auth'; + +@Injectable() +export class MockMainAuthGuard extends MainAuthGuard implements CanActivate { + async canActivate(context: ExecutionContext): Promise { + const req = this.getRequest(context); + const authHeader = req?.headers?.authorization || ''; + const token = authHeader.split(' ')[1]; // Format: 'Bearer userId role' + const role = + authHeader.split(' ')[2]?.toUpperCase() || UserRole.WORKSPACE_MEMBER; + + if (token) { + const user: AuthenticatedUser = { + userId: token, + role, + name: 'Test User', + email: 'test@email.com', + isVerified: true, + isOnboarded: true, + }; + + if (req) { + this.setUser(context, user); + } + } + + return super.canActivate(context); + } + + private getRequest(context: ExecutionContext) { + if (context.getType() === 'graphql') { + return GqlExecutionContext.create(context).getContext().req; + } + + return context.switchToHttp().getRequest(); + } + + private setUser(context: ExecutionContext, user: AuthenticatedUser) { + if (context.getType() === 'graphql') { + GqlExecutionContext.create(context).getContext().user = user; + } else { + context.switchToHttp().getRequest().user = user; + } + } +} diff --git a/apps/core-e2e/src/support/common.module.mock.ts b/apps/core-e2e/src/support/common.module.mock.ts new file mode 100644 index 0000000..bd3ff38 --- /dev/null +++ b/apps/core-e2e/src/support/common.module.mock.ts @@ -0,0 +1,33 @@ +import { Global, Module, ValidationPipe } from '@nestjs/common'; +import { APP_PIPE } from '@nestjs/core'; +import { MockMainAuthGuard } from './auth.guard.mock'; +import { + DataLoaderInterceptor, + PaginationService, +} from '@launchline/core-common'; + +@Global() +@Module({ + providers: [ + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + transformOptions: { enableImplicitConversion: true }, + }), + }, + { + provide: 'APP_GUARD', + useClass: MockMainAuthGuard, + }, + { + provide: 'APP_INTERCEPTOR', + useClass: DataLoaderInterceptor, + }, + PaginationService, + ], + exports: [PaginationService], +}) +export class MockCommonModule {} diff --git a/apps/core-e2e/src/support/config.module.mock.ts b/apps/core-e2e/src/support/config.module.mock.ts new file mode 100644 index 0000000..59c58e0 --- /dev/null +++ b/apps/core-e2e/src/support/config.module.mock.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Global() +@Module({ + providers: [ + { + provide: ConfigService, + useFactory: () => { + return { + get: jest.fn((_key: string, defaultValue?: unknown) => defaultValue), + getOrThrow: jest.fn(), + }; + }, + }, + ], + exports: [ConfigService], +}) +export class MockConfigModule {} diff --git a/apps/core-e2e/src/support/db.module.mock.ts b/apps/core-e2e/src/support/db.module.mock.ts new file mode 100644 index 0000000..986c0b5 --- /dev/null +++ b/apps/core-e2e/src/support/db.module.mock.ts @@ -0,0 +1,33 @@ +import { Global, Module } from '@nestjs/common'; +import { DB_CONNECTION } from '@launchline/core-common'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { PoolConfig } from 'pg'; + +@Global() +@Module({ + providers: [ + { + provide: DB_CONNECTION, + useFactory: () => { + console.log('connecting to db with', { + user: process.env, + password: process.env['DB_PASS'], + database: process.env['DB_DATABASE'], + host: process.env['DB_HOST'], + port: parseInt(process.env['DB_PORT'], 10), + }); + return drizzle({ + connection: { + user: process.env['DB_USERNAME'] as string, + password: process.env['DB_PASS'], + database: process.env['DB_DATABASE'], + host: process.env['DB_HOST'], + port: parseInt(process.env['DB_PORT'], 10), + } satisfies PoolConfig, + }); + }, + }, + ], + exports: [DB_CONNECTION], +}) +export class MockDbModule {} diff --git a/apps/core-e2e/src/support/eventbus.module.mock.ts b/apps/core-e2e/src/support/eventbus.module.mock.ts new file mode 100644 index 0000000..635a690 --- /dev/null +++ b/apps/core-e2e/src/support/eventbus.module.mock.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; +import { EventBusService } from '@launchline/core-common'; + +@Global() +@Module({ + providers: [ + { + provide: EventBusService, + useFactory: () => { + return { + publish: jest.fn(), + validateMessageString: jest.fn(), + }; + }, + }, + ], + exports: [EventBusService], +}) +export class MockEventBusModule {} diff --git a/apps/core-e2e/src/support/global-setup.ts b/apps/core-e2e/src/support/global-setup.ts index 76a5879..95e1975 100644 --- a/apps/core-e2e/src/support/global-setup.ts +++ b/apps/core-e2e/src/support/global-setup.ts @@ -1,16 +1,10 @@ -import { waitForPortOpen } from '@nx/node/utils'; - /* eslint-disable */ var __TEARDOWN_MESSAGE__: string; module.exports = async function () { - // Start services that that the app needs to run (e.g. database, docker-compose, etc.). + // Start services that the app needs to run (e.g. database, docker-compose, etc.). console.log('\nSetting up...\n'); - const host = process.env.HOST ?? 'localhost'; - const port = process.env.PORT ? Number(process.env.PORT) : 3000; - await waitForPortOpen(port, { host }); - // Hint: Use `globalThis` to pass variables to global teardown. globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; }; diff --git a/apps/core-e2e/src/support/global-teardown.ts b/apps/core-e2e/src/support/global-teardown.ts index a28dd11..6368a9d 100644 --- a/apps/core-e2e/src/support/global-teardown.ts +++ b/apps/core-e2e/src/support/global-teardown.ts @@ -1,10 +1,5 @@ -import { killPort } from '@nx/node/utils'; -/* eslint-disable */ - module.exports = async function () { // Put clean up logic here (e.g. stopping services, docker-compose, etc.). // Hint: `globalThis` is shared between setup and teardown. - const port = process.env.PORT ? Number(process.env.PORT) : 3000; - await killPort(port); console.log(globalThis.__TEARDOWN_MESSAGE__); }; diff --git a/apps/core-e2e/src/support/redis.module.mock.ts b/apps/core-e2e/src/support/redis.module.mock.ts new file mode 100644 index 0000000..e981e16 --- /dev/null +++ b/apps/core-e2e/src/support/redis.module.mock.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; +import { PUB_SUB } from '@launchline/core-common'; + +@Global() +@Module({ + providers: [ + { + provide: PUB_SUB, + useFactory: () => { + return { + publish: jest.fn(), + asyncIterator: jest.fn(), + }; + }, + }, + ], + exports: [PUB_SUB], +}) +export class MockRedisModule {} diff --git a/package.json b/package.json index 7685b1b..fda1257 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "postcss": "^8.5", "prettier": "~3.6.2", "run-script-webpack-plugin": "^0.2.3", + "supertest": "^7.2.2", "tailwindcss": "^4.1.18", "ts-jest": "^29.4.0", "ts-node": "10.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49b224d..df4bd99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,9 @@ importers: run-script-webpack-plugin: specifier: ^0.2.3 version: 0.2.3 + supertest: + specifier: ^7.2.2 + version: 7.2.2 tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -2948,6 +2951,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3363,6 +3370,9 @@ packages: cpu: [x64] os: [win32] + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -5908,6 +5918,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -6443,6 +6456,9 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -6519,6 +6535,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cookies@0.9.1: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} @@ -6976,6 +6995,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -7791,6 +7813,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -9457,6 +9483,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -10643,6 +10674,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} @@ -11595,6 +11630,14 @@ packages: peerDependencies: graphql: ^15.7.2 || ^16.0.0 + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -15102,6 +15145,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.10': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -15910,6 +15955,10 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.16.2': optional: true + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -18564,6 +18613,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asap@2.0.6: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -19147,6 +19198,8 @@ snapshots: commondir@1.0.1: {} + component-emitter@1.3.1: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -19216,6 +19269,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + cookies@0.9.1: dependencies: depd: 2.0.0 @@ -19678,6 +19733,11 @@ snapshots: dependencies: dequal: 2.0.3 + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@4.0.2: {} dir-glob@3.0.1: @@ -20806,6 +20866,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -22932,6 +22998,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -24159,6 +24227,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} querystringify@2.2.0: {} @@ -25310,6 +25382,28 @@ snapshots: - bufferutil - utf-8-validate + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 From fa5e13e7d2cf06cc661e13089bd5ba13e4d5d3b9 Mon Sep 17 00:00:00 2001 From: dkarasiewicz Date: Sat, 24 Jan 2026 01:27:14 +0100 Subject: [PATCH 2/5] LAU-27: fix tsconfig.spec.json --- libs/core/auth/src/lib/auth.service.spec.ts | 7 ++----- libs/core/auth/tsconfig.spec.json | 5 +++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/libs/core/auth/src/lib/auth.service.spec.ts b/libs/core/auth/src/lib/auth.service.spec.ts index 6bc6c7b..e032e5e 100644 --- a/libs/core/auth/src/lib/auth.service.spec.ts +++ b/libs/core/auth/src/lib/auth.service.spec.ts @@ -32,13 +32,10 @@ describe('AuthService', () => { }, }, { - provide: 'TWILIO_CLIENT', + provide: 'ANALYTICS_CLIENT', useValue: { verify: { - services: jest.fn().mockReturnThis(), - verifications: { - create: jest.fn(), - }, + capture: jest.fn(), }, }, }, diff --git a/libs/core/auth/tsconfig.spec.json b/libs/core/auth/tsconfig.spec.json index 2d2760d..25e0ebe 100644 --- a/libs/core/auth/tsconfig.spec.json +++ b/libs/core/auth/tsconfig.spec.json @@ -2,8 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "module": "nodenext", - "moduleResolution": "nodenext", + "module": "commonjs", + "moduleResolution": "node10", + "esModuleInterop": true, "types": ["jest", "node"] }, "include": [ From 3cc5b51aadb9b0852319e9dbcef028892350d8fc Mon Sep 17 00:00:00 2001 From: dkarasiewicz Date: Sat, 24 Jan 2026 01:30:42 +0100 Subject: [PATCH 3/5] LAU-27: remove console.log --- apps/core-e2e/src/support/db.module.mock.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/core-e2e/src/support/db.module.mock.ts b/apps/core-e2e/src/support/db.module.mock.ts index 986c0b5..aff81eb 100644 --- a/apps/core-e2e/src/support/db.module.mock.ts +++ b/apps/core-e2e/src/support/db.module.mock.ts @@ -9,13 +9,6 @@ import { PoolConfig } from 'pg'; { provide: DB_CONNECTION, useFactory: () => { - console.log('connecting to db with', { - user: process.env, - password: process.env['DB_PASS'], - database: process.env['DB_DATABASE'], - host: process.env['DB_HOST'], - port: parseInt(process.env['DB_PORT'], 10), - }); return drizzle({ connection: { user: process.env['DB_USERNAME'] as string, From c2d7c0bed142041a326a1ea21a6f0b5db4b0c47b Mon Sep 17 00:00:00 2001 From: dkarasiewicz Date: Sat, 24 Jan 2026 01:33:40 +0100 Subject: [PATCH 4/5] LAU-27: fix integration test CI --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e5e58c..e9bd36e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: - uses: nrwl/nx-set-shas@v4 - - run: pnpm exec nx migrate-dev core + - run: pnpm exec nx migrate-run core # Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud # - run: pnpm exec nx-cloud record -- echo Hello World From 7addc3929d77c6dca1953a6e3ca4fb22e214d35c Mon Sep 17 00:00:00 2001 From: dkarasiewicz Date: Sat, 24 Jan 2026 02:11:50 +0100 Subject: [PATCH 5/5] LAU-27: fix ui related CI --- apps/customer-ui/specs/index.spec.tsx | 4 ++++ .../ui/src/components/assistant-ui/index.ts | 16 ++++------------ libs/shared/client/ui/src/lib/posthog.spec.tsx | 12 ++++++++++++ 3 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 libs/shared/client/ui/src/lib/posthog.spec.tsx diff --git a/apps/customer-ui/specs/index.spec.tsx b/apps/customer-ui/specs/index.spec.tsx index b544e83..39ee328 100644 --- a/apps/customer-ui/specs/index.spec.tsx +++ b/apps/customer-ui/specs/index.spec.tsx @@ -2,6 +2,10 @@ import React from 'react'; import { render } from '@testing-library/react'; import Page from '../src/app/page'; +global.fetch = jest.fn().mockResolvedValue({ + stargazers_count: 100, +}); + describe('Page', () => { it('should render successfully', () => { const { baseElement } = render(); diff --git a/libs/shared/client/ui/src/components/assistant-ui/index.ts b/libs/shared/client/ui/src/components/assistant-ui/index.ts index 53ed979..23055c3 100644 --- a/libs/shared/client/ui/src/components/assistant-ui/index.ts +++ b/libs/shared/client/ui/src/components/assistant-ui/index.ts @@ -1,18 +1,10 @@ -/** - * Launchline Assistant-UI Components - * - * This module exports all assistant-ui components and utilities - * for integrating the Linea DeepAgent with the Launchline web application. - */ - -// Thread component -export { Thread } from '@launchline/ui/components/assistant-ui/thread'; +export { Thread } from './thread'; // Markdown rendering -export { MarkdownText } from '@launchline/ui/components/assistant-ui/markdown-text'; +export { MarkdownText } from './markdown-text'; // Tool fallback -export { ToolFallback } from '@launchline/ui/components/assistant-ui/tool-fallback'; +export { ToolFallback } from './tool-fallback'; // Linea Tool UIs export { @@ -22,4 +14,4 @@ export { SendSlackMessageToolUI, GenerateProjectUpdateTool, WriteTodosToolUI, -} from '@launchline/ui/components/tools/linea/LineaTools'; +} from '../tools/linea/LineaTools'; diff --git a/libs/shared/client/ui/src/lib/posthog.spec.tsx b/libs/shared/client/ui/src/lib/posthog.spec.tsx new file mode 100644 index 0000000..b943c45 --- /dev/null +++ b/libs/shared/client/ui/src/lib/posthog.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { PostHogProvider } from './posthog'; + +describe('PostHogProvider', () => { + it('should render successfully', () => { + const { baseElement } = render( + Test Child, + ); + expect(baseElement).toBeTruthy(); + }); +});