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); }); });