Browse Source

fix: test code by essential-test-patterns

Ryotaro Nagahara 1 month ago
parent
commit
11e6ffc8c6

+ 110 - 0
apps/app/src/features/news/server/routes/news-integration.integ.ts

@@ -147,6 +147,90 @@ describe('News API Integration', () => {
     });
   });
 
+  describe('GET /apiv3/news/list - sort order', () => {
+    test('should return items sorted by publishedAt descending', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'oldest',
+          title: { ja_JP: 'Oldest' },
+          publishedAt: new Date('2026-01-01'),
+          fetchedAt: now,
+        },
+        {
+          externalId: 'newest',
+          title: { ja_JP: 'Newest' },
+          publishedAt: new Date('2026-03-01'),
+          fetchedAt: now,
+        },
+        {
+          externalId: 'middle',
+          title: { ja_JP: 'Middle' },
+          publishedAt: new Date('2026-02-01'),
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+
+      expect(res.status).toBe(200);
+      const ids = res.body.docs.map(
+        (d: { externalId: string }) => d.externalId,
+      );
+      expect(ids).toEqual(['newest', 'middle', 'oldest']);
+    });
+  });
+
+  describe('markRead → listForUser cross-method consistency', () => {
+    test('should reflect isRead=true after mark-read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'cross-test',
+        title: { ja_JP: 'Cross test' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+
+      // Before mark-read: isRead should be false
+      const before = await request(app).get('/apiv3/news/list');
+      expect(before.body.docs[0].isRead).toBe(false);
+
+      // Mark as read
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      // After mark-read: isRead should be true
+      const after = await request(app).get('/apiv3/news/list');
+      expect(after.body.docs[0].isRead).toBe(true);
+    });
+
+    test('should decrease unread-count after mark-read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'count-test',
+        title: { ja_JP: 'Count test' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+
+      const before = await request(app).get('/apiv3/news/unread-count');
+      expect(before.body.count).toBe(1);
+
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      const after = await request(app).get('/apiv3/news/unread-count');
+      expect(after.body.count).toBe(0);
+    });
+  });
+
   describe('GET /apiv3/news/unread-count', () => {
     test('should return 0 after mark-all-read', async () => {
       const now = new Date();
@@ -172,5 +256,31 @@ describe('News API Integration', () => {
       expect(res.status).toBe(200);
       expect(res.body.count).toBe(0);
     });
+
+    test('should not count admin-only items for general user', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-news',
+          title: { ja_JP: 'Admin only' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'general-news',
+          title: { ja_JP: 'General' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp({ admin: false });
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      // Contract: general user only sees 1 unread (not the admin-only item)
+      expect(res.body.count).toBe(1);
+    });
   });
 });

+ 196 - 40
apps/app/src/features/news/server/services/news-service.spec.ts

@@ -113,53 +113,135 @@ describe('NewsService', () => {
       expect(read?.isRead).toBe(true);
     });
 
-    test('should filter by targetRoles when conditions are set', async () => {
-      const userId = new mongoose.Types.ObjectId();
+    test('should not include items with non-matching targetRoles in docs', async () => {
+      const generalItemId = new mongoose.Types.ObjectId();
 
+      // Mock returns both items (simulating DB returning role-filtered results)
       mocks.newsItemFind.mockReturnValue({
         sort: vi.fn().mockReturnThis(),
         skip: vi.fn().mockReturnThis(),
         limit: vi.fn().mockReturnThis(),
-        lean: vi.fn().mockResolvedValue([]),
+        lean: vi
+          .fn()
+          .mockResolvedValue([
+            {
+              _id: generalItemId,
+              externalId: 'general-news',
+              title: { ja_JP: 'General' },
+              publishedAt: new Date(),
+              fetchedAt: new Date(),
+            },
+          ]),
       });
-      mocks.newsItemCountDocuments.mockResolvedValue(0);
+      mocks.newsItemCountDocuments.mockResolvedValue(1);
       mocks.newsReadStatusDistinct.mockResolvedValue([]);
 
-      await service.listForUser(userId, ['general'], { limit: 10, offset: 0 });
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
 
-      const findCall = mocks.newsItemFind.mock.calls[0][0];
-      expect(findCall).toMatchObject({
-        $or: expect.arrayContaining([
-          { 'conditions.targetRoles': { $exists: false } },
-          { 'conditions.targetRoles': { $size: 0 } },
-          { 'conditions.targetRoles': { $in: ['general'] } },
-        ]),
-      });
+      // Contract: only items matching user's role appear in docs
+      expect(result.docs).toHaveLength(1);
+      expect(result.docs.every((d) => d._id.equals(generalItemId))).toBe(true);
     });
 
-    test('should filter onlyUnread when specified', async () => {
-      const userId = new mongoose.Types.ObjectId();
+    test('should exclude read items from docs when onlyUnread is true', async () => {
+      const unreadId = new mongoose.Types.ObjectId();
       const readId = new mongoose.Types.ObjectId();
-      mocks.newsReadStatusDistinct.mockResolvedValue([readId]);
 
+      mocks.newsReadStatusDistinct.mockResolvedValue([readId]);
+      // When onlyUnread=true, DB query already excludes read items
       mocks.newsItemFind.mockReturnValue({
         sort: vi.fn().mockReturnThis(),
         skip: vi.fn().mockReturnThis(),
         limit: vi.fn().mockReturnThis(),
-        lean: vi.fn().mockResolvedValue([]),
+        lean: vi
+          .fn()
+          .mockResolvedValue([
+            {
+              _id: unreadId,
+              externalId: 'unread-news',
+              title: { ja_JP: 'Unread' },
+              publishedAt: new Date(),
+              fetchedAt: new Date(),
+            },
+          ]),
       });
-      mocks.newsItemCountDocuments.mockResolvedValue(0);
+      mocks.newsItemCountDocuments.mockResolvedValue(1);
 
-      await service.listForUser(userId, ['general'], {
-        limit: 10,
-        offset: 0,
-        onlyUnread: true,
-      });
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0, onlyUnread: true },
+      );
 
-      const findCall = mocks.newsItemFind.mock.calls[0][0];
-      expect(findCall).toMatchObject({
-        _id: { $nin: [readId] },
+      // Contract: no read item appears in output
+      expect(result.docs).toHaveLength(1);
+      expect(result.docs[0].isRead).toBe(false);
+      expect(result.docs.some((d) => d._id.equals(readId))).toBe(false);
+    });
+
+    test('should return correct pagination metadata', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p1',
+            title: { ja_JP: 'P1' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p2',
+            title: { ja_JP: 'P2' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p3',
+            title: { ja_JP: 'P3' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p4',
+            title: { ja_JP: 'P4' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p5',
+            title: { ja_JP: 'P5' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
       });
+      mocks.newsItemCountDocuments.mockResolvedValue(23);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 5, offset: 10 },
+      );
+
+      // Contract: pagination fields are correct for offset=10, limit=5, total=23
+      expect(result.totalDocs).toBe(23);
+      expect(result.limit).toBe(5);
+      expect(result.page).toBe(3);
+      expect(result.totalPages).toBe(5);
+      expect(result.hasNextPage).toBe(true);
+      expect(result.hasPrevPage).toBe(true);
     });
   });
 
@@ -188,33 +270,107 @@ describe('NewsService', () => {
     });
   });
 
+  describe('markAllRead', () => {
+    test('should complete without error when news items exist', async () => {
+      const itemId = new mongoose.Types.ObjectId();
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([{ _id: itemId }]),
+      });
+      mocks.newsReadStatusInsertMany.mockResolvedValue([]);
+
+      const userId = new mongoose.Types.ObjectId();
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+    });
+
+    test('should complete without error when no news items exist', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([]),
+      });
+
+      const userId = new mongoose.Types.ObjectId();
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+
+      // Contract: no write operation when nothing to mark
+      expect(mocks.newsReadStatusInsertMany).not.toHaveBeenCalled();
+    });
+
+    test('should silently ignore duplicate key errors (already-read items)', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: new mongoose.Types.ObjectId() }]),
+      });
+      const duplicateError = Object.assign(new Error('duplicate key'), {
+        code: 11000,
+      });
+      mocks.newsReadStatusInsertMany.mockRejectedValue(duplicateError);
+
+      const userId = new mongoose.Types.ObjectId();
+      // Contract: idempotent — calling twice doesn't throw
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+    });
+
+    test('should throw non-duplicate errors', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: new mongoose.Types.ObjectId() }]),
+      });
+      const otherError = Object.assign(new Error('connection lost'), {
+        code: 12345,
+      });
+      mocks.newsReadStatusInsertMany.mockRejectedValue(otherError);
+
+      const userId = new mongoose.Types.ObjectId();
+      // Contract: real errors propagate to caller
+      await expect(service.markAllRead(userId, ['general'])).rejects.toThrow(
+        'connection lost',
+      );
+    });
+  });
+
   describe('getUnreadCount', () => {
     test('should return the number of unread items', async () => {
-      const id1 = new mongoose.Types.ObjectId();
-
-      mocks.newsReadStatusDistinct.mockResolvedValue([id1]);
+      mocks.newsReadStatusDistinct.mockResolvedValue([
+        new mongoose.Types.ObjectId(),
+      ]);
       mocks.newsItemCountDocuments.mockResolvedValue(2);
 
       const userId = new mongoose.Types.ObjectId();
       const count = await service.getUnreadCount(userId, ['general']);
-      expect(count).toBe(2);
 
-      expect(mocks.newsItemCountDocuments).toHaveBeenCalledWith(
-        expect.objectContaining({
-          _id: { $nin: [id1] },
-        }),
-      );
+      // Contract: returns the unread count as a number
+      expect(count).toBe(2);
     });
 
     test('should return 0 when all items are read', async () => {
-      const id1 = new mongoose.Types.ObjectId();
-      const id2 = new mongoose.Types.ObjectId();
+      mocks.newsReadStatusDistinct.mockResolvedValue([
+        new mongoose.Types.ObjectId(),
+        new mongoose.Types.ObjectId(),
+      ]);
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+
+      const count = await service.getUnreadCount(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+      );
+      expect(count).toBe(0);
+    });
 
-      mocks.newsReadStatusDistinct.mockResolvedValue([id1, id2]);
+    test('should return 0 when no news items exist', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
       mocks.newsItemCountDocuments.mockResolvedValue(0);
 
-      const userId = new mongoose.Types.ObjectId();
-      const count = await service.getUnreadCount(userId, ['general']);
+      const count = await service.getUnreadCount(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+      );
       expect(count).toBe(0);
     });
   });