A personal media tracker built on the AT Protocol
opnshelf.xyz
1import { BadRequestException } from "@nestjs/common";
2import { ConfigService } from "@nestjs/config";
3import { Test, type TestingModule } from "@nestjs/testing";
4import type { Response } from "express";
5
6// Mock PrismaService before importing AuthController/AuthService
7jest.mock("../prisma/prisma.service", () => ({
8 PrismaService: jest.fn(),
9}));
10
11// Mock @atproto modules to prevent import errors
12jest.mock("@atproto/oauth-client-node", () => ({}));
13jest.mock("@atproto/api", () => ({}));
14jest.mock("@atproto/tap", () => ({
15 Tap: jest.fn(),
16 SimpleIndexer: jest.fn(),
17}));
18
19import { IngesterService } from "../ingester/ingester.service";
20import { UsersService } from "../users/users.service";
21import { AuthController } from "./auth.controller";
22import { AuthService } from "./auth.service";
23
24describe("AuthController", () => {
25 let controller: AuthController;
26
27 const mockAuthService: {
28 getClientMetadata: jest.Mock;
29 authorize: jest.Mock;
30 authorizeWithPds: jest.Mock;
31 callback: jest.Mock;
32 parseOAuthAppState: jest.Mock;
33 fetchProfile: jest.Mock;
34 upsertUser: jest.Mock;
35 getSessionByUserDid: jest.Mock;
36 getUser: jest.Mock;
37 hasBlueskyProfile: jest.Mock;
38 revokeBySessionId: jest.Mock;
39 } = {
40 getClientMetadata: jest.fn(),
41 authorize: jest.fn(),
42 authorizeWithPds: jest.fn(),
43 callback: jest.fn(),
44 parseOAuthAppState: jest.fn().mockReturnValue({}),
45 fetchProfile: jest.fn(),
46 upsertUser: jest.fn(),
47 getSessionByUserDid: jest.fn(),
48 getUser: jest.fn(),
49 hasBlueskyProfile: jest.fn().mockResolvedValue(false),
50 revokeBySessionId: jest.fn(),
51 };
52
53 const mockIngesterService = {
54 addRepo: jest.fn().mockResolvedValue(undefined),
55 };
56
57 const mockUsersService = {
58 initializeProfileForNewUser: jest.fn().mockResolvedValue(undefined),
59 };
60
61 const mockConfigService = {
62 get: jest.fn((key: string) => {
63 const config: Record<string, string> = {
64 FRONTEND_URL: "http://127.0.0.1:3000",
65 NODE_ENV: "test",
66 };
67 return config[key];
68 }),
69 };
70
71 const createMockResponse = () => {
72 const res = {
73 redirect: jest.fn().mockReturnThis(),
74 cookie: jest.fn().mockReturnThis(),
75 clearCookie: jest.fn().mockReturnThis(),
76 status: jest.fn().mockReturnThis(),
77 json: jest.fn().mockReturnThis(),
78 } as unknown as jest.Mocked<Response>;
79 return res;
80 };
81
82 const createMockRequest = (
83 overrides: Partial<import("express").Request> = {},
84 ) => {
85 return {
86 url: "/auth/callback",
87 cookies: {},
88 ...overrides,
89 } as unknown as import("express").Request;
90 };
91
92 beforeEach(async () => {
93 jest.clearAllMocks();
94 mockAuthService.parseOAuthAppState.mockReturnValue({});
95
96 const module: TestingModule = await Test.createTestingModule({
97 controllers: [AuthController],
98 providers: [
99 { provide: AuthService, useValue: mockAuthService },
100 { provide: ConfigService, useValue: mockConfigService },
101 { provide: IngesterService, useValue: mockIngesterService },
102 { provide: UsersService, useValue: mockUsersService },
103 ],
104 }).compile();
105
106 controller = module.get<AuthController>(AuthController);
107 });
108
109 describe("getClientMetadata", () => {
110 it("should return client metadata from auth service", () => {
111 const mockMetadata = {
112 client_id:
113 "http://127.0.0.1:3001/.well-known/oauth-client-metadata.json",
114 client_name: "OpnShelf",
115 };
116 mockAuthService.getClientMetadata.mockReturnValue(mockMetadata);
117
118 const result = controller.getClientMetadata();
119
120 expect(result).toEqual(mockMetadata);
121 expect(mockAuthService.getClientMetadata).toHaveBeenCalled();
122 });
123 });
124
125 describe("login", () => {
126 it("should redirect to auth URL on success", async () => {
127 const authUrl = "https://bsky.social/oauth/authorize?state=abc";
128 mockAuthService.authorize.mockResolvedValue(authUrl);
129 const res = createMockResponse();
130
131 await controller.login("user.bsky.social", undefined, undefined, res);
132
133 expect(mockAuthService.authorize).toHaveBeenCalledWith(
134 "user.bsky.social",
135 {
136 platform: undefined,
137 timezone: undefined,
138 },
139 );
140 expect(res.redirect).toHaveBeenCalledWith(authUrl);
141 });
142
143 it("should redirect with error when handle is not provided", async () => {
144 const res = createMockResponse();
145
146 await controller.login(undefined, undefined, undefined, res);
147
148 expect(mockAuthService.authorize).not.toHaveBeenCalled();
149 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login");
150 });
151
152 it("should redirect to mobile login when handle is not provided on mobile", async () => {
153 const res = createMockResponse();
154
155 await controller.login(undefined, "mobile", undefined, res);
156
157 expect(mockAuthService.authorize).not.toHaveBeenCalled();
158 expect(res.redirect).toHaveBeenCalledWith(
159 "opnshelf://auth/complete?error=handle_required",
160 );
161 });
162
163 it("should set platform cookie when platform=mobile", async () => {
164 const authUrl = "https://bsky.social/oauth/authorize?state=abc";
165 mockAuthService.authorize.mockResolvedValue(authUrl);
166 const res = createMockResponse();
167
168 await controller.login("user.bsky.social", "mobile", undefined, res);
169
170 expect(mockAuthService.authorize).toHaveBeenCalledWith(
171 "user.bsky.social",
172 {
173 platform: "mobile",
174 timezone: undefined,
175 },
176 );
177 expect(res.cookie).toHaveBeenCalledWith("auth_platform", "mobile", {
178 httpOnly: true,
179 maxAge: 5 * 60 * 1000,
180 sameSite: "lax",
181 });
182 expect(res.redirect).toHaveBeenCalledWith(authUrl);
183 });
184
185 it("should set timezone cookie when timezone provided", async () => {
186 const authUrl = "https://bsky.social/oauth/authorize?state=abc";
187 mockAuthService.authorize.mockResolvedValue(authUrl);
188 const res = createMockResponse();
189
190 await controller.login(
191 "user.bsky.social",
192 undefined,
193 "Europe/London",
194 res,
195 );
196
197 expect(mockAuthService.authorize).toHaveBeenCalledWith(
198 "user.bsky.social",
199 {
200 platform: undefined,
201 timezone: "Europe/London",
202 },
203 );
204 expect(res.cookie).toHaveBeenCalledWith(
205 "auth_timezone",
206 "Europe/London",
207 {
208 httpOnly: true,
209 maxAge: 5 * 60 * 1000,
210 sameSite: "lax",
211 },
212 );
213 expect(res.redirect).toHaveBeenCalledWith(authUrl);
214 });
215
216 it("should redirect to frontend with error on failure", async () => {
217 mockAuthService.authorize.mockRejectedValue(new Error("OAuth error"));
218 const res = createMockResponse();
219
220 await controller.login("user.bsky.social", undefined, undefined, res);
221
222 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login");
223 });
224
225 it("should redirect to mobile login on failure when platform is mobile", async () => {
226 mockAuthService.authorize.mockRejectedValue(new Error("OAuth error"));
227 const res = createMockResponse();
228
229 await controller.login("user.bsky.social", "mobile", undefined, res);
230
231 expect(res.redirect).toHaveBeenCalledWith(
232 "opnshelf://auth/complete?error=auth_failed",
233 );
234 });
235 });
236
237 describe("signup", () => {
238 it("should redirect to frontend with error on signup failure", async () => {
239 mockAuthService.authorizeWithPds.mockRejectedValue(
240 new Error("OAuth error"),
241 );
242 const res = createMockResponse();
243
244 await controller.signup(undefined, undefined, res);
245
246 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login");
247 });
248
249 it("should redirect to mobile login on signup failure when platform is mobile", async () => {
250 mockAuthService.authorizeWithPds.mockRejectedValue(
251 new Error("OAuth error"),
252 );
253 const res = createMockResponse();
254
255 await controller.signup("mobile", undefined, res);
256
257 expect(res.redirect).toHaveBeenCalledWith(
258 "opnshelf://auth/complete?error=auth_failed",
259 );
260 });
261 });
262
263 describe("callback", () => {
264 it("should set cookie and redirect to /auth/complete on success", async () => {
265 const mockSession = { did: "did:plc:abc123" };
266 const mockProfile = {
267 did: "did:plc:abc123",
268 handle: "user.bsky.social",
269 displayName: "Test User",
270 avatar: "https://example.com/avatar.jpg",
271 };
272 const mockSessionRecord = {
273 id: "session-123",
274 userDid: "did:plc:abc123",
275 };
276
277 mockAuthService.callback.mockResolvedValue({ session: mockSession });
278 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
279 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
280 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
281
282 const req = createMockRequest({
283 url: "/auth/callback?code=abc&state=xyz",
284 });
285 const res = createMockResponse();
286
287 await controller.callback(req, res);
288
289 expect(mockAuthService.callback).toHaveBeenCalled();
290 expect(mockAuthService.fetchProfile).toHaveBeenCalledWith(mockSession);
291 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
292 mockProfile,
293 undefined,
294 );
295 expect(res.cookie).toHaveBeenCalledWith(
296 "session",
297 "session-123",
298 expect.objectContaining({
299 httpOnly: true,
300 sameSite: "lax",
301 path: "/",
302 }),
303 );
304 expect(res.redirect).toHaveBeenCalledWith(
305 "http://127.0.0.1:3000/auth/complete",
306 );
307 });
308
309 it("initializes the seeded profile and default lists for new users", async () => {
310 const mockSession = { did: "did:plc:new123" };
311 const mockProfile = {
312 did: "did:plc:new123",
313 handle: "new-user.bsky.social",
314 displayName: "New User",
315 avatar: "https://example.com/avatar.jpg",
316 };
317 const mockSessionRecord = {
318 id: "session-123",
319 userDid: "did:plc:new123",
320 };
321
322 mockAuthService.callback.mockResolvedValue({ session: mockSession });
323 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
324 mockAuthService.upsertUser.mockResolvedValue({
325 user: mockProfile,
326 isNewUser: true,
327 });
328 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
329
330 const req = createMockRequest({
331 url: "/auth/callback?code=abc&state=xyz",
332 });
333 const res = createMockResponse();
334
335 await controller.callback(req, res);
336
337 expect(mockUsersService.initializeProfileForNewUser).toHaveBeenCalledWith(
338 "did:plc:new123",
339 mockSession,
340 {
341 handle: "new-user.bsky.social",
342 displayName: "New User",
343 avatarUrl: "https://example.com/avatar.jpg",
344 },
345 );
346 });
347
348 it("should register user DID with TAP on successful callback", async () => {
349 const mockSession = { did: "did:plc:abc123" };
350 const mockProfile = {
351 did: "did:plc:abc123",
352 handle: "user.bsky.social",
353 displayName: "Test User",
354 avatar: "https://example.com/avatar.jpg",
355 };
356 const mockSessionRecord = {
357 id: "session-123",
358 userDid: "did:plc:abc123",
359 };
360
361 mockAuthService.callback.mockResolvedValue({ session: mockSession });
362 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
363 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
364 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
365
366 const req = createMockRequest({
367 url: "/auth/callback?code=abc&state=xyz",
368 });
369 const res = createMockResponse();
370
371 await controller.callback(req, res);
372
373 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
374 mockProfile,
375 undefined,
376 );
377 expect(mockIngesterService.addRepo).toHaveBeenCalledWith(
378 "did:plc:abc123",
379 );
380 });
381
382 it("should still redirect on success even if TAP registration fails", async () => {
383 const mockSession = { did: "did:plc:abc123" };
384 const mockProfile = {
385 did: "did:plc:abc123",
386 handle: "user.bsky.social",
387 displayName: "Test User",
388 avatar: "https://example.com/avatar.jpg",
389 };
390 const mockSessionRecord = {
391 id: "session-123",
392 userDid: "did:plc:abc123",
393 };
394
395 mockAuthService.callback.mockResolvedValue({ session: mockSession });
396 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
397 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
398 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
399 mockIngesterService.addRepo.mockRejectedValue(new Error("TAP error"));
400
401 const req = createMockRequest({
402 url: "/auth/callback?code=abc&state=xyz",
403 });
404 const res = createMockResponse();
405
406 await controller.callback(req, res);
407
408 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
409 mockProfile,
410 undefined,
411 );
412 expect(res.redirect).toHaveBeenCalledWith(
413 "http://127.0.0.1:3000/auth/complete",
414 );
415 });
416
417 it("should redirect to mobile deep link when platform cookie is set", async () => {
418 const mockSession = { did: "did:plc:abc123" };
419 const mockProfile = {
420 did: "did:plc:abc123",
421 handle: "user.bsky.social",
422 displayName: "Test User",
423 avatar: "https://example.com/avatar.jpg",
424 };
425 const mockSessionRecord = {
426 id: "session-123",
427 userDid: "did:plc:abc123",
428 };
429
430 mockAuthService.callback.mockResolvedValue({ session: mockSession });
431 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
432 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
433 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
434
435 const req = createMockRequest({
436 url: "/auth/callback?code=abc&state=xyz",
437 cookies: { auth_platform: "mobile" },
438 });
439 const res = createMockResponse();
440
441 await controller.callback(req, res);
442
443 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
444 mockProfile,
445 undefined,
446 );
447 expect(res.clearCookie).toHaveBeenCalledWith("auth_platform");
448 expect(res.redirect).toHaveBeenCalledWith(
449 "opnshelf://auth/complete?session=session-123",
450 );
451 });
452
453 it("should redirect to mobile deep link when state contains mobile platform", async () => {
454 const mockSession = { did: "did:plc:abc123" };
455 const mockProfile = {
456 did: "did:plc:abc123",
457 handle: "user.bsky.social",
458 displayName: "Test User",
459 avatar: "https://example.com/avatar.jpg",
460 };
461 const mockSessionRecord = {
462 id: "session-123",
463 userDid: "did:plc:abc123",
464 };
465
466 mockAuthService.callback.mockResolvedValue({
467 session: mockSession,
468 state: '{"platform":"mobile"}',
469 });
470 mockAuthService.parseOAuthAppState.mockReturnValue({
471 platform: "mobile",
472 });
473 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
474 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
475 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
476
477 const req = createMockRequest({
478 url: "/auth/callback?code=abc&state=xyz",
479 cookies: {},
480 });
481 const res = createMockResponse();
482
483 await controller.callback(req, res);
484
485 expect(mockAuthService.parseOAuthAppState).toHaveBeenCalledWith(
486 '{"platform":"mobile"}',
487 );
488 expect(res.redirect).toHaveBeenCalledWith(
489 "opnshelf://auth/complete?session=session-123",
490 );
491 });
492
493 it("should redirect with error when session record not found", async () => {
494 const mockSession = { did: "did:plc:abc123" };
495 const mockProfile = {
496 did: "did:plc:abc123",
497 handle: "user.bsky.social",
498 displayName: null,
499 avatar: null,
500 };
501
502 mockAuthService.callback.mockResolvedValue({ session: mockSession });
503 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
504 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
505 mockAuthService.getSessionByUserDid.mockResolvedValue(null);
506
507 const req = createMockRequest({
508 url: "/auth/callback?code=abc&state=xyz",
509 });
510 const res = createMockResponse();
511
512 await controller.callback(req, res);
513
514 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
515 mockProfile,
516 undefined,
517 );
518 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login");
519 });
520
521 it("should redirect to mobile login when session record not found for mobile state", async () => {
522 const mockSession = { did: "did:plc:abc123" };
523 const mockProfile = {
524 did: "did:plc:abc123",
525 handle: "user.bsky.social",
526 displayName: null,
527 avatar: null,
528 };
529
530 mockAuthService.callback.mockResolvedValue({
531 session: mockSession,
532 state: '{"platform":"mobile"}',
533 });
534 mockAuthService.parseOAuthAppState.mockReturnValue({
535 platform: "mobile",
536 });
537 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
538 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
539 mockAuthService.getSessionByUserDid.mockResolvedValue(null);
540
541 const req = createMockRequest({
542 url: "/auth/callback?code=abc&state=xyz",
543 });
544 const res = createMockResponse();
545
546 await controller.callback(req, res);
547
548 expect(res.redirect).toHaveBeenCalledWith(
549 "opnshelf://auth/complete?error=callback_failed",
550 );
551 });
552
553 it("should redirect with error on callback failure", async () => {
554 mockAuthService.callback.mockRejectedValue(new Error("OAuth error"));
555
556 const req = createMockRequest({
557 url: "/auth/callback?code=abc&state=xyz",
558 });
559 const res = createMockResponse();
560
561 await controller.callback(req, res);
562
563 expect(res.redirect).toHaveBeenCalledWith("http://127.0.0.1:3000/login");
564 });
565
566 it("should redirect to mobile deep link on callback failure when error state is mobile", async () => {
567 const error = new Error("OAuth error") as Error & { state?: string };
568 error.state = '{"platform":"mobile"}';
569 mockAuthService.callback.mockRejectedValue(error);
570 mockAuthService.parseOAuthAppState.mockReturnValue({
571 platform: "mobile",
572 });
573
574 const req = createMockRequest({
575 url: "/auth/callback?code=abc&state=xyz",
576 });
577 const res = createMockResponse();
578
579 await controller.callback(req, res);
580
581 expect(res.redirect).toHaveBeenCalledWith(
582 "opnshelf://auth/complete?error=callback_failed",
583 );
584 });
585
586 it("should redirect to mobile login on callback failure when mobile cookie is set", async () => {
587 mockAuthService.callback.mockRejectedValue(new Error("OAuth error"));
588
589 const req = createMockRequest({
590 url: "/auth/callback?code=abc&state=xyz",
591 cookies: { auth_platform: "mobile" },
592 });
593 const res = createMockResponse();
594
595 await controller.callback(req, res);
596
597 expect(res.redirect).toHaveBeenCalledWith(
598 "opnshelf://auth/complete?error=callback_failed",
599 );
600 });
601 });
602
603 describe("me", () => {
604 it("should return user data when authenticated", async () => {
605 const mockUser = {
606 did: "did:plc:abc123",
607 handle: "user.bsky.social",
608 displayName: "Test User",
609 avatar: "https://example.com/avatar.jpg",
610 onboardingCompletedAt: new Date("2026-01-01T00:00:00.000Z"),
611 };
612 mockAuthService.getUser.mockResolvedValue(mockUser);
613
614 const req = createMockRequest({
615 user: { did: "did:plc:abc123", session: {} },
616 } as unknown as import("express").Request);
617
618 const result = await controller.me(
619 req as unknown as import("../auth/types").AuthenticatedRequest,
620 );
621
622 expect(result).toEqual({
623 did: "did:plc:abc123",
624 handle: "user.bsky.social",
625 displayName: "Test User",
626 avatar: "https://example.com/avatar.jpg",
627 onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
628 needsOnboarding: false,
629 });
630 expect(mockAuthService.getUser).toHaveBeenCalledWith("did:plc:abc123");
631 expect(mockAuthService.hasBlueskyProfile).not.toHaveBeenCalled();
632 });
633
634 it("should throw BadRequestException when no user in request", async () => {
635 const req = createMockRequest();
636
637 await expect(
638 controller.me(
639 req as unknown as import("../auth/types").AuthenticatedRequest,
640 ),
641 ).rejects.toThrow(BadRequestException);
642 });
643
644 it("should throw BadRequestException when user not found in DB", async () => {
645 mockAuthService.getUser.mockResolvedValue(null);
646
647 const req = createMockRequest({
648 user: { did: "did:plc:abc123", session: {} },
649 } as unknown as import("express").Request);
650
651 await expect(
652 controller.me(
653 req as unknown as import("../auth/types").AuthenticatedRequest,
654 ),
655 ).rejects.toThrow(BadRequestException);
656 });
657 });
658
659 describe("blueskyProfileStatus", () => {
660 it("should return Bluesky profile status when authenticated", async () => {
661 mockAuthService.hasBlueskyProfile.mockResolvedValue(true);
662
663 const req = createMockRequest({
664 user: { did: "did:plc:abc123", session: {} },
665 } as unknown as import("express").Request);
666
667 const result = await controller.blueskyProfileStatus(
668 req as unknown as import("../auth/types").AuthenticatedRequest,
669 );
670
671 expect(result).toEqual({ hasBlueskyProfile: true });
672 expect(mockAuthService.hasBlueskyProfile).toHaveBeenCalledWith(
673 "did:plc:abc123",
674 );
675 });
676
677 it("should throw BadRequestException when no user in request", async () => {
678 const req = createMockRequest();
679
680 await expect(
681 controller.blueskyProfileStatus(
682 req as unknown as import("../auth/types").AuthenticatedRequest,
683 ),
684 ).rejects.toThrow(BadRequestException);
685 });
686 });
687
688 describe("logout", () => {
689 it("should revoke session and clear cookie", async () => {
690 const req = createMockRequest({
691 cookies: { session: "session-123" },
692 user: { did: "did:plc:abc123", session: {} },
693 } as unknown as import("express").Request);
694 const res = createMockResponse();
695
696 await controller.logout(
697 req as unknown as import("../auth/types").AuthenticatedRequest,
698 res,
699 );
700
701 expect(mockAuthService.revokeBySessionId).toHaveBeenCalledWith(
702 "session-123",
703 );
704 expect(res.clearCookie).toHaveBeenCalledWith(
705 "session",
706 expect.objectContaining({
707 httpOnly: true,
708 sameSite: "lax",
709 path: "/",
710 }),
711 );
712 expect(res.status).toHaveBeenCalledWith(200);
713 expect(res.json).toHaveBeenCalledWith({
714 message: "Logged out successfully",
715 });
716 });
717
718 it("should still clear cookie when no session exists", async () => {
719 const req = createMockRequest({
720 cookies: {},
721 user: { did: "did:plc:abc123", session: {} },
722 } as unknown as import("express").Request);
723 const res = createMockResponse();
724
725 await controller.logout(
726 req as unknown as import("../auth/types").AuthenticatedRequest,
727 res,
728 );
729
730 expect(mockAuthService.revokeBySessionId).not.toHaveBeenCalled();
731 expect(res.clearCookie).toHaveBeenCalled();
732 expect(res.status).toHaveBeenCalledWith(200);
733 });
734 });
735
736 describe("getCookieDomain (via callback)", () => {
737 it("should not set domain in development", async () => {
738 const mockSession = { did: "did:plc:abc123" };
739 const mockProfile = {
740 did: "did:plc:abc123",
741 handle: "user.bsky.social",
742 displayName: null,
743 avatar: null,
744 };
745 const mockSessionRecord = {
746 id: "session-123",
747 userDid: "did:plc:abc123",
748 };
749
750 mockAuthService.callback.mockResolvedValue({ session: mockSession });
751 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
752 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
753 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
754
755 const req = createMockRequest({ url: "/auth/callback?code=abc" });
756 const res = createMockResponse();
757
758 await controller.callback(req, res);
759
760 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
761 mockProfile,
762 undefined,
763 );
764 // In test/dev mode, domain should not be set
765 expect(res.cookie).toHaveBeenCalledWith(
766 "session",
767 "session-123",
768 expect.not.objectContaining({ domain: expect.any(String) }),
769 );
770 });
771
772 it("should set domain in production", async () => {
773 // Override to production config
774 mockConfigService.get.mockImplementation((key: string) => {
775 const config: Record<string, string> = {
776 FRONTEND_URL: "https://opnshelf.xyz",
777 NODE_ENV: "production",
778 };
779 return config[key];
780 });
781
782 const mockSession = { did: "did:plc:abc123" };
783 const mockProfile = {
784 did: "did:plc:abc123",
785 handle: "user.bsky.social",
786 displayName: null,
787 avatar: null,
788 };
789 const mockSessionRecord = {
790 id: "session-123",
791 userDid: "did:plc:abc123",
792 };
793
794 mockAuthService.callback.mockResolvedValue({ session: mockSession });
795 mockAuthService.fetchProfile.mockResolvedValue(mockProfile);
796 mockAuthService.upsertUser.mockResolvedValue(mockProfile);
797 mockAuthService.getSessionByUserDid.mockResolvedValue(mockSessionRecord);
798
799 const req = createMockRequest({ url: "/auth/callback?code=abc" });
800 const res = createMockResponse();
801
802 await controller.callback(req, res);
803
804 expect(mockAuthService.upsertUser).toHaveBeenCalledWith(
805 mockProfile,
806 undefined,
807 );
808 expect(res.cookie).toHaveBeenCalledWith(
809 "session",
810 "session-123",
811 expect.objectContaining({
812 secure: true,
813 domain: "opnshelf.xyz",
814 }),
815 );
816 });
817 });
818});