randomayzer/tests/vk-r3-safety.test.ts

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