|
1 | 1 | import request from 'supertest'; |
2 | 2 | import { FastifyInstance } from 'fastify'; |
3 | 3 | import { DataSource } from 'typeorm'; |
| 4 | +import type { Pool } from 'pg'; |
4 | 5 | import createOrGetConnection from '../../src/db'; |
5 | 6 | import { saveFixtures } from '../helpers'; |
6 | 7 | import { User } from '../../src/entity/user/User'; |
| 8 | +import { DeletedUser } from '../../src/entity/user/DeletedUser'; |
| 9 | +import { Source } from '../../src/entity/Source'; |
| 10 | +import { sourcesFixture } from '../fixture/source'; |
| 11 | +import { Feed } from '../../src/entity/Feed'; |
| 12 | +import { UserAction, UserActionType } from '../../src/entity/user/UserAction'; |
| 13 | +import { DigestPost } from '../../src/entity/posts/DigestPost'; |
| 14 | +import { DIGEST_SOURCE } from '../../src/entity/Source'; |
| 15 | +import { |
| 16 | + ClaimableItem, |
| 17 | + ClaimableItemTypes, |
| 18 | +} from '../../src/entity/ClaimableItem'; |
| 19 | +import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob'; |
| 20 | +import { OpportunityUser } from '../../src/entity/opportunities/user'; |
| 21 | +import { OpportunityUserType } from '../../src/entity/opportunities/types'; |
| 22 | +import { OpportunityState } from '@dailydotdev/schema'; |
| 23 | +import { |
| 24 | + DEFAULT_NOTIFICATION_SETTINGS, |
| 25 | + NotificationPreferenceStatus, |
| 26 | + NotificationType, |
| 27 | +} from '../../src/notifications/common'; |
| 28 | +import { generateShortId } from '../../src/ids'; |
7 | 29 | import { usersFixture } from '../fixture'; |
8 | 30 | import { ioRedisPool } from '../../src/redis'; |
9 | 31 | import * as betterAuthModule from '../../src/betterAuth'; |
10 | 32 |
|
| 33 | +jest.mock('../../src/common/paddle/index.ts', () => ({ |
| 34 | + ...(jest.requireActual('../../src/common/paddle/index.ts') as Record< |
| 35 | + string, |
| 36 | + unknown |
| 37 | + >), |
| 38 | + paddleInstance: { |
| 39 | + subscriptions: { |
| 40 | + update: jest.fn().mockResolvedValue({}), |
| 41 | + }, |
| 42 | + }, |
| 43 | +})); |
| 44 | + |
| 45 | +jest.mock('../../src/cio', () => ({ |
| 46 | + ...(jest.requireActual('../../src/cio') as Record<string, unknown>), |
| 47 | + identifyAnonymousFunnelSubscription: jest.fn().mockResolvedValue(undefined), |
| 48 | +})); |
| 49 | + |
| 50 | +jest.mock('better-auth/api', () => ({ |
| 51 | + ...(jest.requireActual('better-auth/api') as Record<string, unknown>), |
| 52 | + getOAuthState: jest.fn().mockResolvedValue(null), |
| 53 | +})); |
| 54 | + |
11 | 55 | let app: FastifyInstance; |
12 | 56 | let con: DataSource; |
13 | 57 |
|
@@ -36,6 +80,7 @@ afterAll(async () => { |
36 | 80 | beforeEach(async () => { |
37 | 81 | jest.clearAllMocks(); |
38 | 82 | await ioRedisPool.execute((client) => client.flushall()); |
| 83 | + await saveFixtures(con, Source, sourcesFixture); |
39 | 84 | await saveFixtures(con, User, usersFixture); |
40 | 85 | }); |
41 | 86 |
|
@@ -69,6 +114,261 @@ describe('betterAuth routes', () => { |
69 | 114 | }); |
70 | 115 | }); |
71 | 116 |
|
| 117 | + describe('user.create.before hook', () => { |
| 118 | + const getBeforeHook = async () => { |
| 119 | + const { getBetterAuthOptions } = await import('../../src/betterAuth'); |
| 120 | + const options = getBetterAuthOptions( |
| 121 | + (con.driver as unknown as { master: Pool }).master, |
| 122 | + ); |
| 123 | + const before = options.databaseHooks?.user?.create?.before; |
| 124 | + if (!before) { |
| 125 | + throw new Error('before hook not configured'); |
| 126 | + } |
| 127 | + return before; |
| 128 | + }; |
| 129 | + |
| 130 | + it('should regenerate id when tracking cookie matches a deleted user', async () => { |
| 131 | + const before = await getBeforeHook(); |
| 132 | + const deletedUserId = 'aBcDeFgHiJkLmNoPqRsTu'; |
| 133 | + await con.getRepository(DeletedUser).save({ id: deletedUserId }); |
| 134 | + |
| 135 | + const result = await before( |
| 136 | + { |
| 137 | + email: 'new-signup@example.com', |
| 138 | + name: 'New Signup', |
| 139 | + emailVerified: false, |
| 140 | + createdAt: new Date(), |
| 141 | + updatedAt: new Date(), |
| 142 | + }, |
| 143 | + { |
| 144 | + request: new Request('http://localhost/auth/sign-up/email', { |
| 145 | + headers: { cookie: `da2=${deletedUserId}` }, |
| 146 | + }), |
| 147 | + body: {}, |
| 148 | + }, |
| 149 | + ); |
| 150 | + |
| 151 | + const assignedId = (result as { data: { id: string } }).data.id; |
| 152 | + expect(assignedId).not.toEqual(deletedUserId); |
| 153 | + |
| 154 | + const stillDeleted = await con |
| 155 | + .getRepository(DeletedUser) |
| 156 | + .findOneBy({ id: deletedUserId }); |
| 157 | + expect(stillDeleted).not.toBeNull(); |
| 158 | + }); |
| 159 | + }); |
| 160 | + |
| 161 | + describe('user.create.after hook', () => { |
| 162 | + const getAfterHook = async () => { |
| 163 | + const { getBetterAuthOptions } = await import('../../src/betterAuth'); |
| 164 | + const options = getBetterAuthOptions( |
| 165 | + (con.driver as unknown as { master: Pool }).master, |
| 166 | + ); |
| 167 | + const after = options.databaseHooks?.user?.create?.after; |
| 168 | + if (!after) { |
| 169 | + throw new Error('after hook not configured'); |
| 170 | + } |
| 171 | + return after; |
| 172 | + }; |
| 173 | + |
| 174 | + const makeContext = ( |
| 175 | + overrides: { |
| 176 | + body?: Record<string, unknown>; |
| 177 | + cookie?: string; |
| 178 | + ip?: string; |
| 179 | + } = {}, |
| 180 | + ) => { |
| 181 | + const headers: Record<string, string> = {}; |
| 182 | + if (overrides.cookie) { |
| 183 | + headers.cookie = overrides.cookie; |
| 184 | + } |
| 185 | + if (overrides.ip) { |
| 186 | + headers['x-forwarded-for'] = overrides.ip; |
| 187 | + } |
| 188 | + return { |
| 189 | + request: new Request('http://localhost/auth/sign-up/email', { |
| 190 | + headers: Object.keys(headers).length ? headers : undefined, |
| 191 | + }), |
| 192 | + body: overrides.body, |
| 193 | + }; |
| 194 | + }; |
| 195 | + |
| 196 | + const createBaseUser = async () => { |
| 197 | + const id = await generateShortId(); |
| 198 | + const email = `hook-${id}@test.com`; |
| 199 | + await con.getRepository(User).save({ |
| 200 | + id, |
| 201 | + name: 'Hook User', |
| 202 | + email, |
| 203 | + username: `u_${id}`, |
| 204 | + image: 'https://daily.dev/fake.jpg', |
| 205 | + createdAt: new Date(), |
| 206 | + }); |
| 207 | + return { id, email }; |
| 208 | + }; |
| 209 | + |
| 210 | + it('should add feed with id equal to user id', async () => { |
| 211 | + const after = await getAfterHook(); |
| 212 | + const user = await createBaseUser(); |
| 213 | + |
| 214 | + await after(user, makeContext()); |
| 215 | + |
| 216 | + const feed = await con.getRepository(Feed).findOneBy({ id: user.id }); |
| 217 | + expect(feed).not.toBeNull(); |
| 218 | + expect(feed!.id).toEqual(user.id); |
| 219 | + expect(feed!.userId).toEqual(user.id); |
| 220 | + }); |
| 221 | + |
| 222 | + it('should create a DigestPost stub', async () => { |
| 223 | + const after = await getAfterHook(); |
| 224 | + const user = await createBaseUser(); |
| 225 | + |
| 226 | + await after(user, makeContext()); |
| 227 | + |
| 228 | + const digestPost = await con |
| 229 | + .getRepository(DigestPost) |
| 230 | + .findOneBy({ authorId: user.id }); |
| 231 | + expect(digestPost).not.toBeNull(); |
| 232 | + expect(digestPost!.sourceId).toBe(DIGEST_SOURCE); |
| 233 | + expect(digestPost!.visible).toBeFalsy(); |
| 234 | + expect(digestPost!.private).toBeTruthy(); |
| 235 | + }); |
| 236 | + |
| 237 | + it('should set UserAction for cores role when ip is present', async () => { |
| 238 | + const after = await getAfterHook(); |
| 239 | + const user = await createBaseUser(); |
| 240 | + |
| 241 | + await after(user, makeContext({ ip: '1.2.3.4' })); |
| 242 | + |
| 243 | + const userAction = await con.getRepository(UserAction).findOneBy({ |
| 244 | + userId: user.id, |
| 245 | + type: UserActionType.CheckedCoresRole, |
| 246 | + }); |
| 247 | + expect(userAction).not.toBeNull(); |
| 248 | + }); |
| 249 | + |
| 250 | + it('should mute marketing notifications when acceptedMarketing is false', async () => { |
| 251 | + const after = await getAfterHook(); |
| 252 | + const user = await createBaseUser(); |
| 253 | + |
| 254 | + await after(user, makeContext({ body: { acceptedMarketing: false } })); |
| 255 | + |
| 256 | + const persisted = await con |
| 257 | + .getRepository(User) |
| 258 | + .findOneBy({ id: user.id }); |
| 259 | + expect( |
| 260 | + persisted!.notificationFlags?.[NotificationType.Marketing], |
| 261 | + ).toEqual({ |
| 262 | + email: NotificationPreferenceStatus.Muted, |
| 263 | + inApp: NotificationPreferenceStatus.Muted, |
| 264 | + }); |
| 265 | + }); |
| 266 | + |
| 267 | + it('should use default notification flags when acceptedMarketing is true', async () => { |
| 268 | + const after = await getAfterHook(); |
| 269 | + const user = await createBaseUser(); |
| 270 | + |
| 271 | + await after(user, makeContext({ body: { acceptedMarketing: true } })); |
| 272 | + |
| 273 | + const persisted = await con |
| 274 | + .getRepository(User) |
| 275 | + .findOneBy({ id: user.id }); |
| 276 | + expect(persisted!.notificationFlags).toEqual( |
| 277 | + DEFAULT_NOTIFICATION_SETTINGS, |
| 278 | + ); |
| 279 | + }); |
| 280 | + |
| 281 | + it('should claim opportunities that user created as anonymous', async () => { |
| 282 | + const after = await getAfterHook(); |
| 283 | + const user = await createBaseUser(); |
| 284 | + |
| 285 | + const opportunity = await con.getRepository(OpportunityJob).save( |
| 286 | + con.getRepository(OpportunityJob).create({ |
| 287 | + title: 'Test', |
| 288 | + tldr: 'Test', |
| 289 | + state: OpportunityState.DRAFT, |
| 290 | + }), |
| 291 | + ); |
| 292 | + |
| 293 | + await con.getRepository(ClaimableItem).save({ |
| 294 | + identifier: user.id, |
| 295 | + type: ClaimableItemTypes.Opportunity, |
| 296 | + flags: { |
| 297 | + opportunityId: opportunity.id, |
| 298 | + }, |
| 299 | + }); |
| 300 | + |
| 301 | + await after(user, makeContext()); |
| 302 | + |
| 303 | + const updatedClaimableItem = await con |
| 304 | + .getRepository(ClaimableItem) |
| 305 | + .findOneBy({ |
| 306 | + identifier: user.id, |
| 307 | + type: ClaimableItemTypes.Opportunity, |
| 308 | + }); |
| 309 | + expect(updatedClaimableItem).not.toBeNull(); |
| 310 | + expect(updatedClaimableItem!.claimedAt).toBeInstanceOf(Date); |
| 311 | + expect(updatedClaimableItem!.claimedById).toBe(user.id); |
| 312 | + |
| 313 | + const opportunityUser = await con |
| 314 | + .getRepository(OpportunityUser) |
| 315 | + .findOneBy({ |
| 316 | + opportunityId: opportunity.id, |
| 317 | + userId: user.id, |
| 318 | + }); |
| 319 | + expect(opportunityUser).toEqual({ |
| 320 | + opportunityId: opportunity.id, |
| 321 | + userId: user.id, |
| 322 | + type: OpportunityUserType.Recruiter, |
| 323 | + }); |
| 324 | + }); |
| 325 | + |
| 326 | + it('should claim opportunities that user created with email', async () => { |
| 327 | + const after = await getAfterHook(); |
| 328 | + const user = await createBaseUser(); |
| 329 | + |
| 330 | + const opportunity = await con.getRepository(OpportunityJob).save( |
| 331 | + con.getRepository(OpportunityJob).create({ |
| 332 | + title: 'Test', |
| 333 | + tldr: 'Test', |
| 334 | + state: OpportunityState.DRAFT, |
| 335 | + }), |
| 336 | + ); |
| 337 | + |
| 338 | + await con.getRepository(ClaimableItem).save({ |
| 339 | + identifier: user.email, |
| 340 | + type: ClaimableItemTypes.Opportunity, |
| 341 | + flags: { |
| 342 | + opportunityId: opportunity.id, |
| 343 | + }, |
| 344 | + }); |
| 345 | + |
| 346 | + await after(user, makeContext()); |
| 347 | + |
| 348 | + const updatedClaimableItem = await con |
| 349 | + .getRepository(ClaimableItem) |
| 350 | + .findOneBy({ |
| 351 | + identifier: user.email, |
| 352 | + type: ClaimableItemTypes.Opportunity, |
| 353 | + }); |
| 354 | + expect(updatedClaimableItem).not.toBeNull(); |
| 355 | + expect(updatedClaimableItem!.claimedAt).toBeInstanceOf(Date); |
| 356 | + expect(updatedClaimableItem!.claimedById).toBe(user.id); |
| 357 | + |
| 358 | + const opportunityUser = await con |
| 359 | + .getRepository(OpportunityUser) |
| 360 | + .findOneBy({ |
| 361 | + opportunityId: opportunity.id, |
| 362 | + userId: user.id, |
| 363 | + }); |
| 364 | + expect(opportunityUser).toEqual({ |
| 365 | + opportunityId: opportunity.id, |
| 366 | + userId: user.id, |
| 367 | + type: OpportunityUserType.Recruiter, |
| 368 | + }); |
| 369 | + }); |
| 370 | + }); |
| 371 | + |
72 | 372 | it('should forward native callback routes to BetterAuth handler', async () => { |
73 | 373 | const getBetterAuthSpy = jest |
74 | 374 | .spyOn(betterAuthModule, 'getBetterAuth') |
|
0 commit comments