diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8b9157f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,63 @@ +name: CI Tests + +on: + push: + branches: [ main, development, testing ] + pull_request: + branches: [ main, development, testing ] + +jobs: + backend-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install backend deps + working-directory: backend + run: npm ci + + - name: Run backend unit tests + working-directory: backend + run: npm run test + + - name: Run backend e2e tests + working-directory: backend + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + run: | + cat < .env.local +SUPABASE_URL=${SUPABASE_URL} +SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} +SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} +PORT=3000 +EOF + npm run test:e2e + + frontend-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install frontend deps + working-directory: frontend + run: npm ci + + - name: Run frontend tests + working-directory: frontend + run: npm run test \ No newline at end of file diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..4cd5c84 --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,104 @@ +import { + BadRequestException, + ConflictException, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; + +// Minimal SupabaseService shape for mocking +interface MockSupabaseClient { + from: jest.Mock; + auth: { + signUp: jest.Mock; + signInWithPassword: jest.Mock; + }; +} + +describe('AuthService (unit)', () => { + let service: AuthService; + let mockSupabaseClient: MockSupabaseClient; + let mockSupabaseService: { + getClient: jest.Mock; + getAnonClient: jest.Mock; + }; + + beforeEach(() => { + mockSupabaseClient = { + from: jest.fn(), + auth: { + signUp: jest.fn(), + signInWithPassword: jest.fn(), + }, + }; + + mockSupabaseService = { + getClient: jest.fn(() => mockSupabaseClient), + getAnonClient: jest.fn(() => mockSupabaseClient), + }; + + service = new AuthService(mockSupabaseService as any); + }); + + describe('signup', () => { + const baseSignupDto = { + email: 'TestUser@example.com', + password: 'Test1234!', + displayName: 'TestUser', + firstName: 'Test', + lastName: 'User', + }; + + it('throws ConflictException when display name is already taken', async () => { + // Mock supabase.from('users').select(...) to return an existing user + mockSupabaseClient.from.mockImplementation((table: string) => { + if (table === 'users') { + return { + select: jest.fn().mockResolvedValue({ + data: [{ display_name: 'testuser' }], + error: null, + }), + } as any; + } + return { select: jest.fn().mockResolvedValue({ data: [], error: null }) } as any; + }); + + await expect(service.signup(baseSignupDto as any)).rejects.toThrow( + ConflictException, + ); + await expect(service.signup(baseSignupDto as any)).rejects.toThrow( + 'This display name is already taken. Please choose a different name.', + ); + }); + + it('throws ConflictException when email is already registered', async () => { + // 1) No duplicate display name + mockSupabaseClient.from.mockImplementation((table: string) => { + if (table === 'users') { + return { + select: jest.fn().mockResolvedValue({ + data: [{ display_name: 'someone-else' }], + error: null, + }), + } as any; + } + return { select: jest.fn().mockResolvedValue({ data: [], error: null }) } as any; + }); + + // 2) Supabase auth.signUp returns an "already registered" error + mockSupabaseClient.auth.signUp.mockResolvedValueOnce({ + data: { user: null, session: null }, + error: { message: 'already registered' }, + }); + + const promise = service.signup(baseSignupDto as any); + + await expect(promise).rejects.toThrow(ConflictException); + await expect(promise).rejects.toThrow( + 'An account with this email already exists.', + ); + }); + }); +}); + + diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index b16c798..07e3d2e 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,16 +1,18 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { config } from 'dotenv'; +import { join } from 'path'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; -import { config } from 'dotenv'; -import { join } from 'path'; -describe('AppController (e2e)', () => { +describe('App e2e', () => { let app: INestApplication; beforeEach(async () => { + // Load test environment (points at test Supabase project, etc.) config({ path: join(__dirname, '..', '.env.local') }); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); @@ -29,4 +31,48 @@ describe('AppController (e2e)', () => { .expect(200) .expect('Hello World!'); }); + + it('Flow 1: signup -> login -> GET /circles returns empty list for new user', async () => { + const server = app.getHttpServer(); + + const ts = Date.now(); + const email = `qa-user-${ts}@example.com`; + const password = 'Test1234!'; + const displayName = `qa_user_${ts}`; + const firstName = 'QA'; + const lastName = 'User'; + + // 1) Sign up a brand new user + await request(server) + .post('/auth/signup') + .send({ + email, + password, + displayName, + firstName, + lastName, + }) + .expect(201); + + // 2) Log in with the same credentials to get a Supabase access token + const loginRes = await request(server) + .post('/auth/login') + .send({ + identifier: email, + password, + }) + .expect(200); + + const accessToken = loginRes.body?.session?.access_token; + expect(accessToken).toBeDefined(); + + // 3) Call /circles with the Bearer token and expect an empty array + const circlesRes = await request(server) + .get('/circles') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + expect(Array.isArray(circlesRes.body)).toBe(true); + expect(circlesRes.body.length).toBe(0); + }); });