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