From e4309f75eb0e4ff0b2269a2a3f18f51d9a8a14b0 Mon Sep 17 00:00:00 2001 From: Aaron Ross Date: Tue, 6 Aug 2024 19:05:36 -0700 Subject: [PATCH] sanity (#4) * sanity * keep around generated files for build * wip * add test coverage * env * pathing * path --- .env.test.example | 7 +- .github/workflows/ci.yml | 53 +++ .gitignore | 2 - generated/i18n/index.ts | 87 +++++ generated/zod/index.ts | 338 +++++++++++++++++++ package.json | 8 +- prisma/singleton.ts | 6 + src/app.controller.spec.ts | 9 +- src/auth/auth.controller.spec.ts | 7 +- src/auth/auth.service.spec.ts | 7 +- src/htmx/htmx.interceptor.spec.ts | 6 +- src/users/users.controller.spec.ts | 7 +- src/users/users.service.spec.ts | 295 +++++++++++++++- src/users/users.service.ts | 43 ++- src/validation/validation.controller.spec.ts | 7 +- src/validation/validation.service.spec.ts | 7 +- src/zod/zod.pipe.spec.ts | 28 ++ test/app.e2e-spec.ts | 6 +- 18 files changed, 901 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 generated/i18n/index.ts create mode 100644 generated/zod/index.ts diff --git a/.env.test.example b/.env.test.example index ed87ad8..b19b580 100644 --- a/.env.test.example +++ b/.env.test.example @@ -2,4 +2,9 @@ DB_URL="file:./test.db" NODE_ENV="test" SESSION_SECRET="test" PORT=9999 -DEBUG_ROUTES=0 \ No newline at end of file +DEBUG_ROUTES=0 +GOOGLE_OAUTH_CLIENT_ID="your client id" +GOOGLE_OAUTH_CLIENT_SECRET="your secret" +GOOGLE_OAUTH_CALLBACK_URL="http://localhost:300/auth/google/callback" +GOOGLE_OAUTH_SCOPE="profile,email" +JWT_SECRET="your secret key" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5fc659d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: NestJS Core CI + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js lts + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: env + run: cp ./.env.test.example ./.env.test + - name: Test + run: npm run test:cov + + build: + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js lts + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index bf72f47..0d52773 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* -# Generated files -/generated # OS .DS_Store diff --git a/generated/i18n/index.ts b/generated/i18n/index.ts new file mode 100644 index 0000000..2482701 --- /dev/null +++ b/generated/i18n/index.ts @@ -0,0 +1,87 @@ +/* DO NOT EDIT, file generated by nestjs-i18n */ + +/* eslint-disable */ +/* prettier-ignore */ +import { Path } from "nestjs-i18n"; +/* prettier-ignore */ +export type I18nTranslations = { + "about": { + "title": string; + "description": string; + }; + "auth": { + "sign_in": { + "title": string; + "email": string; + "password": string; + "sign_in": string; + "forgot_password": string; + "no_account": string; + "google": string; + "submit": string; + }; + "register": { + "title": string; + "email": string; + "password": string; + "confirm_password": string; + "register": string; + "already_have_account": string; + "google": string; + "submit": string; + }; + "forgot_password": { + "title": string; + "email": string; + "forgot_pass": string; + "remember_password": string; + "no_account": string; + "submit": string; + }; + }; + "contact": { + "title": string; + "description": string; + }; + "meta": { + "title": string; + "description": string; + }; + "privacy": { + "title": string; + "description": string; + }; + "root": { + "links": { + "home": string; + "about": string; + "contact": string; + "privacy": string; + "tou": string; + "sign_in": string; + "register": string; + "profile": string; + }; + "app_name": string; + "welcome": string; + "description": string; + }; + "tou": { + "title": string; + "description": string; + }; + "main": { + "links": { + "home": string; + "about": string; + "contact": string; + "privacy": string; + "tou": string; + }; + "app_name": string; + "welcome": string; + "description": string; + }; +}; +/* prettier-ignore */ +export type I18nPath = Path; diff --git a/generated/zod/index.ts b/generated/zod/index.ts new file mode 100644 index 0000000..cfab2d2 --- /dev/null +++ b/generated/zod/index.ts @@ -0,0 +1,338 @@ +import { z } from 'zod'; +import type { Prisma } from '@prisma/client'; + +///////////////////////////////////////// +// HELPER FUNCTIONS +///////////////////////////////////////// + + +///////////////////////////////////////// +// ENUMS +///////////////////////////////////////// + +export const TransactionIsolationLevelSchema = z.enum(['Serializable']); + +export const UserScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','status']); + +export const CredentialScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','user_id','type','external_id','value','expires_at','refresh_token']); + +export const PiiScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','user_id','type','value']); + +export const LoginAttemptScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','user_id','success']); + +export const VerificationTokenScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','user_id','token','type']); + +export const SessionScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','user_id']); + +export const RoleScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','name','description']); + +export const PermissionScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','name','description']); + +export const RolePermissionsScalarFieldEnumSchema = z.enum(['id','created_at','updated_at','role_id','permission_id']); + +export const UserRolesScalarFieldEnumSchema = z.enum(['id','user_id','role_id']); + +export const SortOrderSchema = z.enum(['asc','desc']); + +export const NullsOrderSchema = z.enum(['first','last']); +///////////////////////////////////////// +// MODELS +///////////////////////////////////////// + +///////////////////////////////////////// +// USER SCHEMA +///////////////////////////////////////// + +export const UserSchema = z.object({ + /** + * Unique identifier for the user. + */ + id: z.string().cuid(), + /** + * Timestamp of user creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last user update. + */ + updated_at: z.coerce.date(), + /** + * Current status of the user (e.g., 'verified', 'unverified'). + */ + status: z.string().regex(/^(unverified|verified)$/), +}) + +export type User = z.infer + +///////////////////////////////////////// +// CREDENTIAL SCHEMA +///////////////////////////////////////// + +export const CredentialSchema = z.object({ + /** + * Unique identifier for the credential. + */ + id: z.string().cuid(), + /** + * Timestamp of credential creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last credential update. + */ + updated_at: z.coerce.date(), + /** + * ID of the user this credential belongs to. + */ + user_id: z.string(), + /** + * Type of credential (e.g., 'google', 'password'). + */ + type: z.string(), + /** + * Id from the external provider + */ + external_id: z.string(), + /** + * Value of the credential (e.g., accessToken, password etc) + */ + value: z.string(), + /** + * Expiration time of the stored value + */ + expires_at: z.coerce.date(), + /** + * Value of refresh token if exists + */ + refresh_token: z.string().nullable(), +}) + +export type Credential = z.infer + +///////////////////////////////////////// +// PII SCHEMA +///////////////////////////////////////// + +export const PiiSchema = z.object({ + /** + * Unique identifier for the personally identifiable information (PII). + */ + id: z.string().cuid(), + /** + * Timestamp of PII creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last PII update. + */ + updated_at: z.coerce.date(), + /** + * ID of the user this PII belongs to. + */ + user_id: z.string(), + /** + * Type of PII (e.g., 'full_name', 'date_of_birth'). + */ + type: z.string(), + /** + * Encrypted value of the PII. + */ + value: z.string(), +}) + +export type Pii = z.infer + +///////////////////////////////////////// +// LOGIN ATTEMPT SCHEMA +///////////////////////////////////////// + +export const LoginAttemptSchema = z.object({ + /** + * Unique identifier for the login attempt. + */ + id: z.string().cuid(), + /** + * Timestamp of the login attempt. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last login attempt update. + */ + updated_at: z.coerce.date(), + /** + * ID of the user who made the login attempt. + */ + user_id: z.string(), + /** + * Indicates whether the login attempt was successful. + */ + success: z.boolean(), +}) + +export type LoginAttempt = z.infer + +///////////////////////////////////////// +// VERIFICATION TOKEN SCHEMA +///////////////////////////////////////// + +export const VerificationTokenSchema = z.object({ + /** + * Unique identifier for the verification token. + */ + id: z.string().cuid(), + /** + * Timestamp of token creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last token update. + */ + updated_at: z.coerce.date(), + /** + * ID of the user this token belongs to. + */ + user_id: z.string(), + /** + * Token value used for verification. + */ + token: z.string(), + /** + * Type of verification (e.g., 'email', 'phone'). + */ + type: z.string(), +}) + +export type VerificationToken = z.infer + +///////////////////////////////////////// +// SESSION SCHEMA +///////////////////////////////////////// + +export const SessionSchema = z.object({ + /** + * Unique identifier for the session. + */ + id: z.string().cuid(), + /** + * Timestamp of session creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last session update. + */ + updated_at: z.coerce.date(), + /** + * ID of the user this session belongs to. + */ + user_id: z.string(), +}) + +export type Session = z.infer + +///////////////////////////////////////// +// ROLE SCHEMA +///////////////////////////////////////// + +export const RoleSchema = z.object({ + /** + * Unique identifier for the role. + */ + id: z.string().cuid(), + /** + * Timestamp of role creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last role update. + */ + updated_at: z.coerce.date(), + /** + * Name of the role (e.g., 'admin', 'user'). + */ + name: z.string(), + /** + * Description of the role and its purpose. + */ + description: z.string(), +}) + +export type Role = z.infer + +///////////////////////////////////////// +// PERMISSION SCHEMA +///////////////////////////////////////// + +export const PermissionSchema = z.object({ + /** + * Unique identifier for the permission. + */ + id: z.string().cuid(), + /** + * Timestamp of permission creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last permission update. + */ + updated_at: z.coerce.date(), + /** + * Name of the permission (e.g., 'create_post', 'edit_user'). + */ + name: z.string(), + /** + * Description of the permission. + */ + description: z.string(), +}) + +export type Permission = z.infer + +///////////////////////////////////////// +// ROLE PERMISSIONS SCHEMA +///////////////////////////////////////// + +export const RolePermissionsSchema = z.object({ + /** + * Unique identifier for the association between a role and a permission. + */ + id: z.string().cuid(), + /** + * Timestamp of the association creation. + */ + created_at: z.coerce.date(), + /** + * Timestamp of the last association update. + */ + updated_at: z.coerce.date(), + /** + * The role ID in this association. + */ + role_id: z.string(), + /** + * The permission ID in this association. + */ + permission_id: z.string(), +}) + +export type RolePermissions = z.infer + +///////////////////////////////////////// +// USER ROLES SCHEMA +///////////////////////////////////////// + +export const UserRolesSchema = z.object({ + /** + * Unique identifier for the association between a user and a role. + */ + id: z.string().cuid(), + /** + * The user ID in this association. + */ + user_id: z.string(), + /** + * The role ID in this association. + */ + role_id: z.string(), +}) + +export type UserRoles = z.infer diff --git a/package.json b/package.json index 807cbbb..c34dcc0 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "start:debug": "uno css && nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "xss-scan && jest --detectOpenHandles", + "test": "xss-scan && jest --verbose", "test:watch": "xss-scan && jest --watch", - "test:cov": "xss-scan && jest --coverage", + "test:cov": "xss-scan && jest --verbose --coverage", "test:debug": "xss-scan && node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "xss-scan && jest --config ./test/jest-e2e.json", "migrate:dev": "npx prisma migrate dev", @@ -110,6 +110,10 @@ "jsx", "tsx" ], + "coveragePathIgnorePatterns": [ + "\\.module\\.ts", + "main.ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { diff --git a/prisma/singleton.ts b/prisma/singleton.ts index ee44a36..4ae1bbc 100644 --- a/prisma/singleton.ts +++ b/prisma/singleton.ts @@ -12,4 +12,10 @@ beforeEach(() => { mockReset(prismaMock); }); +afterAll(async () => { + await prisma.$disconnect(); + jest.restoreAllMocks(); + return; +}); + export const prismaMock = prisma as unknown as DeepMockProxy; diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index 26c62e6..ad701d1 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -7,10 +7,11 @@ import { I18nModule } from 'nestjs-i18n'; import i18n_opts from './config/i18n'; describe('AppController', () => { + let module: TestingModule; let controller: AppController; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ ...opts, envFilePath: '.env.test' }), I18nModule.forRoot(i18n_opts), @@ -21,12 +22,16 @@ describe('AppController', () => { controller = module.get(AppController); }); + afterEach(async () => { + await module.close(); + }); + it('should be defined', () => { expect(controller).toBeDefined(); }); it('should return an html fragment injected with app title', () => { - const result = controller.main(); + const result = controller.index(); expect(isHtmlFragment(result)).toBe(true); expect(isHtmlDocument(result)).toBe(false); expect(result).toMatch(/NestJsx/); diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts index 90a0599..e92f623 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/auth/auth.controller.spec.ts @@ -11,10 +11,11 @@ import i18n_opts from '@core/config/i18n'; import opts from '@core/config/app'; describe('AuthController', () => { + let module: TestingModule; let controller: AuthController; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ ...opts, @@ -33,6 +34,10 @@ describe('AuthController', () => { controller = module.get(AuthController); }); + afterEach(async () => { + await module.close(); + return; + }); it('should be defined', () => { expect(controller).toBeDefined(); diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 14acd2f..89db642 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -6,10 +6,11 @@ import { PrismaService } from 'nestjs-prisma'; import { prismaMock } from '../../prisma/singleton'; describe('AuthService', () => { + let module: TestingModule; let service: AuthService; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ imports: [JwtModule], providers: [ AuthService, @@ -20,6 +21,10 @@ describe('AuthService', () => { service = module.get(AuthService); }); + afterEach(async () => { + await module.close(); + return; + }); it('should be defined', () => { expect(service).toBeDefined(); diff --git a/src/htmx/htmx.interceptor.spec.ts b/src/htmx/htmx.interceptor.spec.ts index 8066078..f395a70 100644 --- a/src/htmx/htmx.interceptor.spec.ts +++ b/src/htmx/htmx.interceptor.spec.ts @@ -8,7 +8,7 @@ import { I18nTranslations } from '@generated/i18n'; describe('HtmxInterceptor', () => { let app: TestingModule; - beforeAll(async () => { + beforeEach(async () => { app = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ @@ -19,6 +19,10 @@ describe('HtmxInterceptor', () => { ], }).compile(); }); + afterEach(async () => { + await app.close(); + return; + }); it('should be defined', () => { const config = app.get>(ConfigService); const i18n = app.get>(I18nService); diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index aa1500f..c4e073e 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -5,10 +5,11 @@ import { PrismaService } from 'nestjs-prisma'; import { prismaMock } from '../../prisma/singleton'; describe('UsersController', () => { + let module: TestingModule; let controller: UsersController; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ controllers: [UsersController], providers: [ UsersService, @@ -21,6 +22,10 @@ describe('UsersController', () => { controller = module.get(UsersController); }); + afterEach(async () => { + await module.close(); + return; + }); it('should be defined', () => { expect(controller).toBeDefined(); diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 58c860d..b078ccb 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -1,13 +1,79 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { UsersService } from './users.service'; +import { + CredentialWithUserPii, + SessionWithUserPii, + UserAndPiiInclude, + UsersService, +} from './users.service'; import { PrismaService } from 'nestjs-prisma'; import { prismaMock } from '../../prisma/singleton'; +import { faker } from '@faker-js/faker'; +import { Pii, User, Credential, Prisma } from '@prisma/client'; +import { ValidGoogleOauthData } from '@core/auth/google-oauth.strategy'; +import { pick } from 'lodash'; describe('UsersService', () => { + let module: TestingModule; let service: UsersService; + const data: ValidGoogleOauthData = { + credential: { + type: 'google', + value: 'google_id', + external_id: 'google_id', + refresh_token: 'refresh_token', + expires_at: faker.date.future(), + }, + pii: [{ type: 'email', value: faker.internet.exampleEmail() }], + }; + const user: User = { + id: faker.string.uuid(), + created_at: faker.date.past(), + updated_at: faker.date.recent(), + status: 'verified', + }; + const pii: Pii[] = [ + { + id: faker.string.uuid(), + type: 'email', + value: data.pii[0].value, + user_id: user.id, + created_at: faker.date.past(), + updated_at: faker.date.recent(), + }, + ]; + const credential: Credential = { + id: faker.string.uuid(), + type: data.credential.type, + value: data.credential.value, + external_id: data.credential.external_id, + refresh_token: data.credential.refresh_token, + expires_at: data.credential.expires_at, + user_id: user.id, + created_at: faker.date.past(), + updated_at: faker.date.recent(), + }; + const credentialWithUserPii: CredentialWithUserPii = { + ...credential, + user: { + ...user, + pii, + }, + } as const; + + const session: SessionWithUserPii = { + id: faker.string.uuid(), + created_at: faker.date.past(), + updated_at: faker.date.recent(), + user_id: user.id, + user: { + ...user, + pii, + }, + } as const; + beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [ UsersService, { provide: PrismaService, useValue: prismaMock }, @@ -16,8 +82,233 @@ describe('UsersService', () => { service = module.get(UsersService); }); + afterEach(async () => { + await module.close(); + return; + }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('findExistingUser', () => { + it('should find a user by external_id', async () => { + prismaMock.user.findFirst.mockResolvedValue(user); + + const result = await service.findExistingUser(data); + + expect(prismaMock.user.findFirst).toHaveBeenCalledWith({ + where: { + OR: [ + { + credentials: { + some: { + external_id: data.credential.external_id, + type: data.credential.type, + }, + }, + }, + { + pii: { + some: { + value: data.pii[0].value, + type: data.pii[0].type, + }, + }, + }, + ], + }, + }); + + expect(result).toEqual(user); + + const result2 = await service.findExistingUser({ + ...data, + pii: [], + }); + + expect(prismaMock.user.findFirst).toHaveBeenCalledWith({ + where: { + OR: [ + { + credentials: { + some: { + external_id: data.credential.external_id, + type: data.credential.type, + }, + }, + }, + ], + }, + }); + + expect(result2).toEqual(user); + }); + it('should return null if no user is found', async () => { + prismaMock.user.findFirst.mockResolvedValue(null); + + const result = await service.findExistingUser(data); + + expect(prismaMock.user.findFirst).toHaveBeenCalledWith({ + where: { + OR: [ + { + credentials: { + some: { + external_id: data.credential.external_id, + type: data.credential.type, + }, + }, + }, + { + pii: { + some: { + value: data.pii[0].value, + type: data.pii[0].type, + }, + }, + }, + ], + }, + }); + + expect(result).toBeNull(); + }); + }); + + describe('getUserByJwt', () => { + it('should get a user by jwt', async () => { + const payload = { sub: session.id }; + + prismaMock.session.findUnique.mockResolvedValue(session); + + const result = await service.getUserByJwt(payload); + + expect(prismaMock.session.findUnique).toHaveBeenCalledWith({ + where: { id: payload.sub }, + ...UserAndPiiInclude, + }); + + expect(result).toEqual(session); + }); + + it('should return null if no session is found', async () => { + const payload = { sub: faker.string.uuid() }; + + prismaMock.session.findUnique.mockResolvedValue(null); + + const result = await service.getUserByJwt(payload); + + expect(prismaMock.session.findUnique).toHaveBeenCalledWith({ + where: { id: payload.sub }, + ...UserAndPiiInclude, + }); + + expect(result).toBeNull(); + }); + }); + + describe('createCredentialedUser', () => { + it('should create a new user', async () => { + prismaMock.credential.create.mockResolvedValue(credentialWithUserPii); + + const args = { + data: { + ...pick(credential, [ + 'type', + 'value', + 'external_id', + 'refresh_token', + 'expires_at', + ]), + user: { + create: { + pii: { + createMany: { + data: pii.map((p) => pick(p, ['type', 'value'])), + }, + }, + }, + }, + }, + ...UserAndPiiInclude, + } satisfies Prisma.CredentialCreateArgs; + + const result = await service.createCredentialedUser(data); + + expect(prismaMock.credential.create).toHaveBeenCalledWith(args); + + expect(result).toEqual(credentialWithUserPii); + }); + }); + + describe('updateCredentialedUser', () => { + it('should update a user', async () => { + const newPii: Pii = { + id: faker.string.uuid(), + created_at: faker.date.past(), + updated_at: faker.date.recent(), + type: 'phone', + value: faker.phone.number(), + user_id: user.id, + }; + const newCredentialWithPii: CredentialWithUserPii = { + ...credentialWithUserPii, + user: { + ...credentialWithUserPii.user, + pii: [...pii, newPii], + }, + }; + prismaMock.credential.upsert.mockResolvedValue(newCredentialWithPii); + const gPii = pick(newPii, [ + 'type', + 'value', + ]) as ValidGoogleOauthData['pii'][number]; + const result = await service.updateCredentialedUser(user, { + ...data, + pii: [...data.pii, gPii], + }); + + expect(prismaMock.pii.upsert).toHaveBeenNthCalledWith(1, { + where: { + type_user_id: { type: 'email', user_id: user.id }, + }, + create: { + ...data.pii[0], + user: { + connect: { id: user.id }, + }, + }, + update: data.pii[0], + }); + expect(prismaMock.pii.upsert).toHaveBeenNthCalledWith(2, { + where: { + type_user_id: { type: 'phone', user_id: user.id }, + }, + create: { + ...gPii, + user: { + connect: { id: user.id }, + }, + }, + update: gPii, + }); + + expect(prismaMock.credential.upsert).toHaveBeenCalledWith({ + where: { + type_external_id: pick(data.credential, ['type', 'external_id']), + }, + create: { + ...data.credential, + user: { + connect: { id: user.id }, + }, + }, + update: data.credential, + ...UserAndPiiInclude, + }); + + expect(result).toEqual(newCredentialWithPii); + }); + }); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index d006671..d0f8ff8 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -20,14 +20,18 @@ export const PiiType: Record = PII_TYPES.reduce( {} as Record, ); -const SessionWithUserWithPiiInclude = { +export const UserAndPiiInclude = { include: { user: { include: { pii: true } }, }, } satisfies Prisma.SessionDefaultArgs; export type SessionWithUserPii = Prisma.SessionGetPayload< - typeof SessionWithUserWithPiiInclude + typeof UserAndPiiInclude +>; + +export type CredentialWithUserPii = Prisma.CredentialGetPayload< + typeof UserAndPiiInclude >; @Injectable() @@ -35,6 +39,11 @@ export class UsersService { #log = new Logger(UsersService.name); constructor(private readonly db: PrismaService) {} + /** + * Finds an existing user by external_id or email + * @param {ValidGoogleOauthData} data - The data to search for + * @returns {Promise} The user if found, otherwise null + */ async findExistingUser(data: ValidGoogleOauthData): Promise { const OR: Prisma.UserWhereInput['OR'] = [ { @@ -57,14 +66,25 @@ export class UsersService { }); } - async getUserByJwt({ sub: id }: JwtPayload) { + /** + * Get a user by their JWT payload + * @param {JwtPayload} jwt - The JWT payload + * @returns {Promise} The user session with associated User and PII + */ + async getUserByJwt(jwt: JwtPayload) { + const { sub: id } = jwt; const session = await this.db.session.findUnique({ where: { id }, - ...SessionWithUserWithPiiInclude, + ...UserAndPiiInclude, }); return session; } + /** + * Create a new user with the given data + * @param {ValidGoogleOauthData} data - The data to create the user with + * @returns {Promise} The user session with associated User and PII + */ async createCredentialedUser(data: ValidGoogleOauthData) { const { credential, pii } = data; return this.db.credential.create({ @@ -78,10 +98,16 @@ export class UsersService { }, }, }, - ...SessionWithUserWithPiiInclude, + ...UserAndPiiInclude, }); } + /** + * Update a user with the given data + * @param {User} user - The user to update + * @param {ValidGoogleOauthData} data - The data to update the user with + * @returns {Promise} The user session with associated User and PII + */ async updateCredentialedUser(user: User, data: ValidGoogleOauthData) { const { credential, pii } = data; await this.db.$transaction( @@ -111,10 +137,15 @@ export class UsersService { }, }, update: credential, - ...SessionWithUserWithPiiInclude, + ...UserAndPiiInclude, }); } + /** + * Upsert a user with the given data + * @param {ValidGoogleOauthData} data - The data to upsert the user with + * @returns {Promise} The user session with associated User and PII + */ async upsertOauthCredentialUser( data: ValidGoogleOauthData, ): Promise { diff --git a/src/validation/validation.controller.spec.ts b/src/validation/validation.controller.spec.ts index 37f8d1e..fbeb8cf 100644 --- a/src/validation/validation.controller.spec.ts +++ b/src/validation/validation.controller.spec.ts @@ -2,15 +2,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ValidationController } from './validation.controller'; describe('ValidationController', () => { + let module: TestingModule; let controller: ValidationController; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ controllers: [ValidationController], }).compile(); controller = module.get(ValidationController); }); + afterEach(async () => { + await module.close(); + return; + }); it('should be defined', () => { expect(controller).toBeDefined(); diff --git a/src/validation/validation.service.spec.ts b/src/validation/validation.service.spec.ts index 6b79f0b..6b613e8 100644 --- a/src/validation/validation.service.spec.ts +++ b/src/validation/validation.service.spec.ts @@ -2,15 +2,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ValidationService } from './validation.service'; describe('ValidationService', () => { + let module: TestingModule; let service: ValidationService; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [ValidationService], }).compile(); service = module.get(ValidationService); }); + afterEach(async () => { + await module.close(); + return; + }); it('should be defined', () => { expect(service).toBeDefined(); diff --git a/src/zod/zod.pipe.spec.ts b/src/zod/zod.pipe.spec.ts index 342fccc..123560e 100644 --- a/src/zod/zod.pipe.spec.ts +++ b/src/zod/zod.pipe.spec.ts @@ -1,7 +1,35 @@ +import { ArgumentMetadata } from '@nestjs/common'; import { ZodValidationPipe } from './zod.pipe'; describe('ZodValidationPipe', () => { it('should be defined', () => { expect(new ZodValidationPipe()).toBeDefined(); }); + describe('transform', () => { + it('should transform a value given a schema', () => { + const pipe = new ZodValidationPipe(); + const schema = { + parse: jest.fn(), + }; + const metadata = { + metatype: { + zodSchema: schema, + }, + }; + const value = 'value'; + pipe.transform(value, metadata as unknown as ArgumentMetadata); + expect(schema.parse).toHaveBeenCalledWith(value); + }); + + it('should return the value if no schema is provided', () => { + const pipe = new ZodValidationPipe(); + const metadata = { + metatype: {}, + }; + const value = 'value'; + expect( + pipe.transform(value, metadata as unknown as ArgumentMetadata), + ).toBe(value); + }); + }); }); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 8cec33a..49365f8 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -6,7 +6,7 @@ import { AppModule } from '../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; - beforeAll(async () => { + beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); @@ -14,6 +14,10 @@ describe('AppController (e2e)', () => { app = moduleFixture.createNestApplication(); await app.init(); }); + afterEach(async () => { + await app.close(); + return; + }); it('/ (GET)', () => { return request(app.getHttpServer())