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

292 lines
14 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);
});
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([]);
});
});