import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; import { fetchPaginatedVk } from '../src/integrations/vk/vk-client'; import { VkIncompleteDataError } from '../src/integrations/vk/vk-errors'; import { VkProvider } from '../src/providers/vk/vk-provider'; import { STATIC_VK_CAPABILITIES } from '../src/providers/vk/vk-capabilities'; import { IVkClient } from '../src/integrations/vk/vk-client'; import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; import { validateProviderCapabilities } from '../src/core/validation/giveaway-schemas'; import { POST as importParticipants } from '../src/app/api/giveaways/[id]/participants/route'; import { GiveawayStore } from '../src/lib/giveaway-store'; import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session'; import { ProviderFactory } from '../src/providers/factory'; import { generalApiRateLimiter, expensiveApiRateLimiter } from '../src/lib/rate-limiter'; describe('R3 VK incomplete and unsupported behavior', () => { it('rejects an empty page before the reported total instead of returning a partial list', async () => { await expect(fetchPaginatedVk({ pageSize: 2, fetchPage: vi.fn().mockResolvedValue({ items: [], totalCount: 2 }), })).rejects.toBeInstanceOf(VkIncompleteDataError); }); it('rejects a short page before the reported total instead of treating it as completion', async () => { await expect(fetchPaginatedVk({ pageSize: 3, fetchPage: vi.fn().mockResolvedValue({ items: [1, 2], totalCount: 4 }), })).rejects.toBeInstanceOf(VkIncompleteDataError); }); it('rejects an empty result without a reported total because completeness is unknown', async () => { await expect(fetchPaginatedVk({ pageSize: 3, fetchPage: vi.fn().mockResolvedValue({ items: [] }), })).rejects.toBeInstanceOf(VkIncompleteDataError); }); it('accepts a complete short final page when it reaches the reported total', async () => { const result = await fetchPaginatedVk({ pageSize: 3, fetchPage: vi.fn() .mockResolvedValueOnce({ items: [1, 2, 3], totalCount: 5 }) .mockResolvedValueOnce({ items: [4, 5], totalCount: 5 }), }); expect(result).toEqual([1, 2, 3, 4, 5]); }); it('rejects invalid totals, totals that change, and oversized pages', async () => { await expect(fetchPaginatedVk({ pageSize: 2, fetchPage: vi.fn().mockResolvedValue({ items: [], totalCount: -1 }), })).rejects.toBeInstanceOf(VkIncompleteDataError); await expect(fetchPaginatedVk({ pageSize: 2, fetchPage: vi.fn() .mockResolvedValueOnce({ items: [1, 2], totalCount: 4 }) .mockResolvedValueOnce({ items: [3], totalCount: 5 }), })).rejects.toBeInstanceOf(VkIncompleteDataError); await expect(fetchPaginatedVk({ pageSize: 2, fetchPage: vi.fn().mockResolvedValue({ items: [1, 2, 3], totalCount: 3 }), })).rejects.toBeInstanceOf(VkIncompleteDataError); }); it('fails subscription enrichment when VK omits any requested user status', async () => { const client = { call: vi.fn().mockResolvedValue([{ user_id: 101, member: 1 }]), } as unknown as IVkClient; const provider = new VkProvider('fake-service-token-r3', client); await expect(provider.checkSubscription(['101', '202'], '42')) .rejects.toBeInstanceOf(VkIncompleteDataError); }); it('fails subscription enrichment for malformed or unexpected VK statuses', async () => { for (const response of [ { items: [{ user_id: 101, member: 1 }] }, [{ user_id: 101, member: 2 }], [{ user_id: 999, member: 1 }], [{ user_id: 101, member: 1 }, { user_id: 101, member: 0 }], ]) { const client = { call: vi.fn().mockResolvedValue(response) } as unknown as IVkClient; const provider = new VkProvider('fake-service-token-r3', client); await expect(provider.checkSubscription(['101'], '42')).rejects.toBeInstanceOf(VkIncompleteDataError); } }); it('keeps unsupported repost and manager checks explicit', () => { expect(STATIC_VK_CAPABILITIES.reposts).toBe(false); expect(STATIC_VK_CAPABILITIES.repostsNote).toMatch(/не подключена/i); expect(STATIC_VK_CAPABILITIES.adminDetection).toBe(false); expect(STATIC_VK_CAPABILITIES.adminDetectionNote).toMatch(/не загружается/i); }); it('rejects repost and manager rules with clear capability reasons', () => { expect(() => validateProviderCapabilities( { ...DEFAULT_FILTER_RULES, requireRepost: true }, STATIC_VK_CAPABILITIES )).toThrow(/репост/i); expect(() => validateProviderCapabilities( { ...DEFAULT_FILTER_RULES, excludeAdmins: true }, STATIC_VK_CAPABILITIES )).toThrow(/руководителей/i); }); it('uses ordinary page requests and never attempts execute batching', async () => { const calls: string[] = []; const client = { call: vi.fn(async (method: string) => { calls.push(method); return { count: 0, items: [] }; }), } as unknown as IVkClient; const provider = new VkProvider('fake-service-token-r3', client); await provider.fetchParticipants({ ownerId: '-42', postId: '7', includeLikes: true, includeComments: true }); expect(calls).toEqual(['likes.getList', 'wall.getComments']); expect(calls).not.toContain('execute'); }); }); describe('R3 import route persistence on incomplete VK data', () => { const user = { id: 'usr_r3_safety', vkUserId: '101' }; let sessionId = ''; beforeEach(async () => { GiveawayStore.setRepository(new MemoryGiveawayRepository()); defaultSessionStore.clear(); generalApiRateLimiter.reset(); expensiveApiRateLimiter.reset(); sessionId = await defaultSessionStore.createSession(user); }); afterEach(() => vi.restoreAllMocks()); it('returns an explicit error and preserves the previous participant list when membership status is incomplete', async () => { const giveaway = await GiveawayStore.create({ sourceUrl: 'https://vk.com/wall-42_7', post: { platform: 'VK', ownerId: '-42', postId: '7', sourceUrl: 'https://vk.com/wall-42_7', title: 'R3 test', text: '', likesCount: 1, commentsCount: 0, repostsCount: 0, }, filterRules: { ...DEFAULT_FILTER_RULES }, winnersCount: 1, reserveWinnersCount: 0, organizerId: user.id, }); const previousParticipants = [{ platformUserId: 'old-participant', firstName: 'Existing', lastName: 'Entry', source: 'LIKES' as const, liked: true, commented: false, commentsCount: 0, reposted: false, subscribed: false, eligible: true, exclusionReason: null, }]; await GiveawayStore.updateParticipants(giveaway.id, previousParticipants); const client = { call: vi.fn(async (method: string) => { if (method === 'likes.getList') { return { count: 2, items: [ { id: 101, first_name: 'One', last_name: 'User' }, { id: 202, first_name: 'Two', last_name: 'User' }, ] }; } if (method === 'wall.getComments') return { count: 0, items: [] }; if (method === 'groups.isMember') return [{ user_id: 101, member: 1 }]; throw new Error(`Unexpected VK method: ${method}`); }), } as unknown as IVkClient; vi.spyOn(ProviderFactory, 'getVkProvider').mockReturnValue( new VkProvider('fake-service-token-r3-route', client) ); const req = new NextRequest(`http://localhost/api/giveaways/${giveaway.id}/participants`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${SESSION_COOKIE_NAME}=${sessionId}`, }, body: JSON.stringify({ filterRules: { ...DEFAULT_FILTER_RULES, requireLike: true, requireComment: true, requireSubscription: true, targetGroupId: '42', }, }), }); const response = await importParticipants(req, { params: { id: giveaway.id } }); const body = await response.json(); expect(response.status).toBe(502); expect(body.error.code).toBe('VK_INCOMPLETE_DATA'); expect(body.error.message).toMatch(/subscription check returned status/i); const stored = await GiveawayStore.getParticipantsPaginated(giveaway.id, 1, 50); expect(stored.participants).toEqual(previousParticipants); }); it('rejects malformed likes/comments records at the import route without replacing stored participants', async () => { const giveaway = await GiveawayStore.create({ sourceUrl: 'https://vk.com/wall-42_7', post: { platform: 'VK', ownerId: '-42', postId: '7', sourceUrl: 'https://vk.com/wall-42_7', title: 'Malformed response test', text: '', likesCount: 1, commentsCount: 0, repostsCount: 0, }, filterRules: { ...DEFAULT_FILTER_RULES }, winnersCount: 1, reserveWinnersCount: 0, organizerId: user.id, }); const previousParticipants = [{ platformUserId: 'previous-user', firstName: 'Previous', lastName: 'Participant', source: 'LIKES' as const, liked: true, commented: false, commentsCount: 0, reposted: false, subscribed: false, eligible: true, exclusionReason: null, }]; await GiveawayStore.updateParticipants(giveaway.id, previousParticipants); const invalidCases: Array<{ label: string; method: 'likes.getList' | 'wall.getComments'; response: unknown }> = [ { label: 'missing likes ID', method: 'likes.getList', response: { count: 1, items: [{ first_name: 'No', last_name: 'ID' }] } }, { label: 'undefined likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: undefined }] } }, { label: 'zero likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: 0 }] } }, { label: 'negative likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: -3 }] } }, { label: 'fractional likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: 1.5 }] } }, { label: 'unsafe likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: Number.MAX_SAFE_INTEGER + 1 }] } }, { label: 'string likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: '101' }] } }, { label: 'null likes item', method: 'likes.getList', response: { count: 1, items: [null] } }, { label: 'duplicate likes IDs', method: 'likes.getList', response: { count: 2, items: [{ id: 101 }, { id: 101 }] } }, { label: 'malformed optional likes profile field', method: 'likes.getList', response: { count: 1, items: [{ id: 101, first_name: {} }] } }, { label: 'missing likes items', method: 'likes.getList', response: { count: 0 } }, { label: 'missing likes count', method: 'likes.getList', response: { items: [] } }, { label: 'missing comments items', method: 'wall.getComments', response: { count: 0 } }, { label: 'null comment item', method: 'wall.getComments', response: { count: 1, items: [null] } }, { label: 'comment missing author ID', method: 'wall.getComments', response: { count: 1, items: [{ id: 1 }] } }, { label: 'zero comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: 0 }] } }, { label: 'string comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: '101' }] } }, { label: 'fractional comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: 1.5 }] } }, { label: 'unsafe comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: Number.MAX_SAFE_INTEGER + 1 }] } }, { label: 'null comments profiles', method: 'wall.getComments', response: { count: 0, items: [], profiles: null } }, { label: 'null comments profile item', method: 'wall.getComments', response: { count: 0, items: [], profiles: [null] } }, { label: 'invalid comments profile ID', method: 'wall.getComments', response: { count: 0, items: [], profiles: [{ id: -101 }] } }, { label: 'malformed comments profile field', method: 'wall.getComments', response: { count: 0, items: [], profiles: [{ id: 101, photo_100: 42 }] } }, { label: 'duplicate comments profiles', method: 'wall.getComments', response: { count: 0, items: [], profiles: [{ id: 101 }, { id: 101 }] } }, { label: 'missing comments count', method: 'wall.getComments', response: { items: [] } }, ]; for (const invalidCase of invalidCases) { expensiveApiRateLimiter.reset(); const client = { call: vi.fn(async (method: string) => { if (method === invalidCase.method) return invalidCase.response; return { count: 0, items: [] }; }), } as unknown as IVkClient; vi.spyOn(ProviderFactory, 'getVkProvider').mockReturnValue( new VkProvider('fake-service-token-malformed-r3', client) ); const req = new NextRequest(`http://localhost/api/giveaways/${giveaway.id}/participants`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `${SESSION_COOKIE_NAME}=${sessionId}`, }, body: JSON.stringify({ filterRules: { ...DEFAULT_FILTER_RULES, requireLike: invalidCase.method === 'likes.getList', requireComment: invalidCase.method === 'wall.getComments', }, }), }); const response = await importParticipants(req, { params: { id: giveaway.id } }); const body = await response.json(); expect(response.status, invalidCase.label).toBe(502); expect(body.error.code, invalidCase.label).toBe('VK_INCOMPLETE_DATA'); const stored = await GiveawayStore.getParticipantsPaginated(giveaway.id, 1, 50); expect(stored.participants, invalidCase.label).toEqual(previousParticipants); } }); it('allows valid negative comment authors as explicitly excluded community authors', async () => { const client = { call: vi.fn().mockResolvedValue({ count: 1, items: [{ id: 1, from_id: -42 }] }), } as unknown as IVkClient; const provider = new VkProvider('fake-service-token-negative-community', client); await expect(provider.fetchParticipants({ ownerId: '-42', postId: '7', includeLikes: false, includeComments: true, })).resolves.toEqual([]); }); });