199 lines
8.4 KiB
TypeScript
199 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|