diff --git a/apps/api/jest.e2e.config.cjs b/apps/api/jest.e2e.config.cjs new file mode 100644 index 0000000..5dec3a7 --- /dev/null +++ b/apps/api/jest.e2e.config.cjs @@ -0,0 +1,19 @@ +module.exports = { + displayName: 'api-e2e', + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + rootDir: __dirname, + testMatch: ['/tests/e2e/**/*.spec.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + globals: { + 'ts-jest': { + useESM: true, + tsconfig: '/tsconfig.spec.json', + }, + }, + coverageDirectory: '../../coverage/apps/api-e2e', +}; diff --git a/apps/api/project.json b/apps/api/project.json index 0832fec..3ecf8f3 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -36,6 +36,13 @@ "config": "apps/api/vitest.config.mts" } }, + "e2e": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/apps/api-e2e"], + "options": { + "command": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c apps/api/jest.e2e.config.cjs" + } + }, "prune-lockfile": { "dependsOn": ["build"], "cache": true, diff --git a/apps/api/tests/e2e/api.e2e.spec.ts b/apps/api/tests/e2e/api.e2e.spec.ts new file mode 100644 index 0000000..7c7cff7 --- /dev/null +++ b/apps/api/tests/e2e/api.e2e.spec.ts @@ -0,0 +1,353 @@ +import 'reflect-metadata'; +import 'reflect-metadata'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import * as bcrypt from 'bcryptjs'; +import { AppModule } from '../../src/app/app.module'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { UserRole } from '../../src/generated/prisma/client'; + +type TestUser = { + id: string; + email: string; + password: string; +}; + +type TestSite = { + id: string; +}; + +const DEFAULT_DB_URL = 'postgresql://postgres:postgres@127.0.0.1:5433/a3service'; + +function ensureTestEnv() { + process.env.NODE_ENV = process.env.NODE_ENV ?? 'test'; + process.env.DATABASE_URL = process.env.DATABASE_URL ?? DEFAULT_DB_URL; + process.env.DIRECT_URL = process.env.DIRECT_URL ?? process.env.DATABASE_URL; + process.env.SUPABASE_SERVICE_ROLE_KEY = + process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'test-supabase-service-role-key'; + process.env.SUPABASE_ANON_KEY = + process.env.SUPABASE_ANON_KEY ?? 'test-supabase-anon-key'; + process.env.JWT_SECRET = + process.env.JWT_SECRET ?? 'test-jwt-secret-32-characters-minimum!'; +} + +async function resetDatabase(prisma: PrismaService) { + await prisma.$executeRawUnsafe(` +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename <> '_prisma_migrations') LOOP + EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' RESTART IDENTITY CASCADE'; + END LOOP; +END $$; +`); +} + +async function seedUser(prisma: PrismaService, role: UserRole, email: string): Promise { + const password = 'Password123!'; + const passwordHash = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { + username: email.split('@')[0], + email, + passwordHash, + role, + }, + }); + + return { id: user.id, email: user.email, password }; +} + +async function seedSite(prisma: PrismaService): Promise { + const client = await prisma.client.create({ + data: { + name: 'Test Client', + email: 'client@example.com', + phone: '123', + }, + }); + + const site = await prisma.site.create({ + data: { + clientId: client.id, + rawAddress: 'Test Address', + latitude: 0, + longitude: 0, + }, + }); + + return { id: site.id }; +} + +async function login(app: INestApplication, email: string, password: string) { + const res = await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ email, password }); + + return res; +} + +describe('API E2E', () => { + let app: INestApplication; + let prisma: PrismaService; + let manager: TestUser; + let technician: TestUser; + let otherTech: TestUser; + let site: TestSite; + + beforeAll(async () => { + ensureTestEnv(); + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: false, + transform: true, + forbidNonWhitelisted: false, + }), + ); + + await app.init(); + prisma = app.get(PrismaService); + }, 30000); + + beforeEach(async () => { + await resetDatabase(prisma); + manager = await seedUser(prisma, UserRole.MANAGER, 'manager@a3.test'); + technician = await seedUser(prisma, UserRole.TECHNICIAN, 'tech@a3.test'); + otherTech = await seedUser(prisma, UserRole.TECHNICIAN, 'tech2@a3.test'); + site = await seedSite(prisma); + }, 30000); + + afterAll(async () => { + await app.close(); + }); + + it('returns health status', async () => { + const res = await request(app.getHttpServer()).get('/api/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBeDefined(); + }); + + it('logs in with valid credentials', async () => { + const res = await login(app, manager.email, manager.password); + expect(res.status).toBe(201); + expect(res.body.access_token).toBeDefined(); + expect(res.body.refresh_token).toBeDefined(); + }); + + it('rejects login with invalid credentials', async () => { + const res = await login(app, manager.email, 'wrong'); + expect(res.status).toBe(401); + }); + + it('rejects refresh with invalid token', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/refresh') + .send({ refresh_token: 'invalid-token' }); + + expect(res.status).toBe(401); + }); + + it('creates a job as manager', async () => { + const auth = await login(app, manager.email, manager.password); + const token = auth.body.access_token as string; + + const res = await request(app.getHttpServer()) + .post('/api/jobs') + .set('Authorization', `Bearer ${token}`) + .send({ + siteId: site.id, + technicianId: technician.id, + scheduledDate: new Date().toISOString(), + estimatedDuration: 60, + }); + + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + }); + + it('returns only technician jobs for that user', async () => { + const managerAuth = await login(app, manager.email, manager.password); + const managerToken = managerAuth.body.access_token as string; + + await request(app.getHttpServer()) + .post('/api/jobs') + .set('Authorization', `Bearer ${managerToken}`) + .send({ + siteId: site.id, + technicianId: technician.id, + scheduledDate: new Date().toISOString(), + }); + + await request(app.getHttpServer()) + .post('/api/jobs') + .set('Authorization', `Bearer ${managerToken}`) + .send({ + siteId: site.id, + technicianId: otherTech.id, + scheduledDate: new Date().toISOString(), + }); + + const techAuth = await login(app, technician.email, technician.password); + const techToken = techAuth.body.access_token as string; + + const res = await request(app.getHttpServer()) + .get('/api/jobs') + .set('Authorization', `Bearer ${techToken}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(1); + expect(res.body[0].technicianId).toBe(technician.id); + }); + + it('creates a service log with totals', async () => { + const managerAuth = await login(app, manager.email, manager.password); + const managerToken = managerAuth.body.access_token as string; + + const jobRes = await request(app.getHttpServer()) + .post('/api/jobs') + .set('Authorization', `Bearer ${managerToken}`) + .send({ + siteId: site.id, + technicianId: technician.id, + scheduledDate: new Date().toISOString(), + }); + + const part = await prisma.part.create({ + data: { + sku: 'PART-1', + name: 'Valve', + aliases: [], + }, + }); + + const techAuth = await login(app, technician.email, technician.password); + const techToken = techAuth.body.access_token as string; + + const res = await request(app.getHttpServer()) + .post('/api/service-logs') + .set('Authorization', `Bearer ${techToken}`) + .send({ + jobId: jobRes.body.id, + laborEntries: [{ hours: 2, hourlyRate: 100 }], + consumedParts: [{ partId: part.id, quantity: 2, unitPrice: 25 }], + }); + + expect(res.status).toBe(201); + expect(res.body.totals).toEqual({ + laborTotal: '200.00', + partsTotal: '50.00', + totalCost: '250.00', + }); + }); + + it('treats service log sync as idempotent', async () => { + const managerAuth = await login(app, manager.email, manager.password); + const managerToken = managerAuth.body.access_token as string; + + const jobRes = await request(app.getHttpServer()) + .post('/api/jobs') + .set('Authorization', `Bearer ${managerToken}`) + .send({ + siteId: site.id, + technicianId: technician.id, + scheduledDate: new Date().toISOString(), + }); + + const part = await prisma.part.create({ + data: { + sku: 'PART-2', + name: 'Pump', + aliases: [], + }, + }); + + const techAuth = await login(app, technician.email, technician.password); + const techToken = techAuth.body.access_token as string; + + const createRes = await request(app.getHttpServer()) + .post('/api/service-logs') + .set('Authorization', `Bearer ${techToken}`) + .send({ + jobId: jobRes.body.id, + laborEntries: [{ hours: 1, hourlyRate: 80 }], + consumedParts: [{ partId: part.id, quantity: 1, unitPrice: 20 }], + }); + + const logId = createRes.body.id as string; + const payload = { + idempotencyKey: 'sync-key-1', + jobId: jobRes.body.id, + laborEntries: [{ hours: 1, hourlyRate: 80 }], + consumedParts: [{ partId: part.id, quantity: 1, unitPrice: 20 }], + }; + + const first = await request(app.getHttpServer()) + .post(`/api/service-logs/${logId}/sync`) + .set('Authorization', `Bearer ${techToken}`) + .send(payload); + + const second = await request(app.getHttpServer()) + .post(`/api/service-logs/${logId}/sync`) + .set('Authorization', `Bearer ${techToken}`) + .send(payload); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(first.body.duplicate).toBe(false); + expect(second.body.duplicate).toBe(true); + }); + + it('reconciles sync logs with idempotency', async () => { + const auth = await login(app, manager.email, manager.password); + const token = auth.body.access_token as string; + + const payload = { + items: [ + { + idempotencyKey: 'reconcile-key-1', + action: 'UPLOAD', + affectedEntity: 'Job', + affectedId: 'job-1', + }, + ], + }; + + const first = await request(app.getHttpServer()) + .post('/api/sync/reconcile') + .set('Authorization', `Bearer ${token}`) + .send(payload); + + const second = await request(app.getHttpServer()) + .post('/api/sync/reconcile') + .set('Authorization', `Bearer ${token}`) + .send(payload); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(first.body.duplicates).toBe(0); + expect(second.body.duplicates).toBe(1); + }); + + it('returns analytics summary for manager', async () => { + const auth = await login(app, manager.email, manager.password); + const token = auth.body.access_token as string; + + const res = await request(app.getHttpServer()) + .get('/api/analytics/summary') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.totals).toBeDefined(); + }); +}); diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json index d8b55f5..f2f41a4 100644 --- a/apps/api/tsconfig.spec.json +++ b/apps/api/tsconfig.spec.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "types": [ + "jest", "vitest/globals", "vitest/importMeta", "vite/client", @@ -16,6 +19,7 @@ "vite.config.mts", "vitest.config.ts", "vitest.config.mts", + "tests/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.test.tsx", diff --git a/apps/api/vitest.config.mts b/apps/api/vitest.config.mts index def4f4b..3f6bd4a 100644 --- a/apps/api/vitest.config.mts +++ b/apps/api/vitest.config.mts @@ -11,7 +11,9 @@ export default defineConfig(() => ({ watch: false, globals: true, environment: 'node', - include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + hookTimeout: 30000, + testTimeout: 20000, reporters: ['default'], coverage: { reportsDirectory: '../../coverage/apps/api', diff --git a/package-lock.json b/package-lock.json index 4a8f125..dcf72b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.20.0", "@types/react": "~19.1.10", + "@types/supertest": "^6.0.3", "@vitest/coverage-v8": "~4.0.0", "babel-jest": "^29.7.0", "babel-preset-expo": "~54.0.7", @@ -105,6 +106,7 @@ "prettier": "~3.6.2", "prisma": "^7.8.0", "react-test-renderer": "19.1.0", + "supertest": "^7.0.0", "ts-jest": "^29.4.0", "ts-node": "10.9.1", "tslib": "^2.3.0", @@ -10432,6 +10434,16 @@ "win32" ] }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -13804,6 +13816,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -14001,6 +14020,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -14165,6 +14191,30 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -17325,6 +17375,16 @@ "dev": true, "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -17529,6 +17589,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", @@ -18893,6 +18960,17 @@ "node": ">= 4.0.0" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -21574,6 +21652,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -35502,6 +35598,55 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "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" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 0db5279..a754ac9 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.20.0", "@types/react": "~19.1.10", + "@types/supertest": "^6.0.3", "@vitest/coverage-v8": "~4.0.0", "babel-jest": "^29.7.0", "babel-preset-expo": "~54.0.7", @@ -66,7 +67,8 @@ "typescript-eslint": "^8.40.0", "vite": "^6.4.2", "vitest": "~4.0.8", - "webpack-cli": "^5.1.4" + "webpack-cli": "^5.1.4", + "supertest": "^7.0.0" }, "dependencies": { "@expo/metro-runtime": "~6.1.2",