UNPKG

@memberjunction/actions-bizapps-social

Version:

Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer

598 lines 23.3 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock dependencies vi.mock('@memberjunction/actions', () => ({ BaseAction: class BaseAction { }, BaseOAuthAction: class BaseOAuthAction { constructor() { this.oauthParams = []; } getAccessToken() { return null; } getRefreshToken() { return null; } getCustomAttribute(_index) { return null; } async updateStoredTokens(_access, _refresh, _expiresIn) { } }, OAuth2Manager: class OAuth2Manager { }, })); vi.mock('@memberjunction/global', () => ({ RegisterClass: () => (target) => target, })); vi.mock('@memberjunction/core', () => ({ UserInfo: class UserInfo { }, Metadata: vi.fn(), LogStatus: vi.fn(), LogError: vi.fn(), RunView: vi.fn().mockImplementation(() => ({ RunView: vi.fn().mockResolvedValue({ Success: true, Results: [] }), })), })); vi.mock('@memberjunction/core-entities', () => ({ MJCompanyIntegrationEntity: class MJCompanyIntegrationEntity { constructor() { this.CompanyID = ''; this.AccessToken = null; this.RefreshToken = null; } }, })); vi.mock('@memberjunction/actions-base', () => ({ ActionParam: class ActionParam { constructor() { this.Name = ''; this.Value = null; this.Type = 'Input'; } }, })); vi.mock('axios', () => { return { default: { post: vi.fn(), isAxiosError: vi.fn(() => false), }, }; }); import { BaseSocialMediaAction } from '../base/base-social.action.js'; import { BufferBaseAction, BufferGraphQLError } from '../providers/buffer/buffer-base.action.js'; import { LinkedInBaseAction } from '../providers/linkedin/linkedin-base.action.js'; // Concrete subclass for testing BaseSocialMediaAction class TestSocialAction extends BaseSocialMediaAction { get platformName() { return 'TestPlatform'; } get apiBaseUrl() { return 'https://api.test.com/v1'; } async uploadSingleMedia() { return 'test-url'; } async searchPosts() { return []; } normalizePost(platformPost) { return { id: String(platformPost['id'] || ''), platform: 'TestPlatform', profileId: '', content: '', mediaUrls: [], publishedAt: new Date(), platformSpecificData: {}, }; } async refreshAccessToken() { } async InternalRunAction() { return { Success: true, ResultCode: 'SUCCESS' }; } } describe('BaseSocialMediaAction', () => { let action; beforeEach(() => { action = new TestSocialAction(); }); describe('normalizeAnalytics', () => { it('should normalize platform data with all fields', () => { const result = action['normalizeAnalytics']({ impressions: 1000, engagements: 200, clicks: 50, shares: 30, comments: 20, likes: 100, reach: 800, saves: 10, videoViews: 500, }); expect(result.impressions).toBe(1000); expect(result.engagements).toBe(200); expect(result.clicks).toBe(50); expect(result.shares).toBe(30); expect(result.comments).toBe(20); expect(result.likes).toBe(100); expect(result.reach).toBe(800); expect(result.saves).toBe(10); expect(result.videoViews).toBe(500); }); it('should default missing fields to 0', () => { const result = action['normalizeAnalytics']({}); expect(result.impressions).toBe(0); expect(result.engagements).toBe(0); expect(result.clicks).toBe(0); expect(result.shares).toBe(0); expect(result.comments).toBe(0); expect(result.likes).toBe(0); expect(result.reach).toBe(0); }); }); describe('validateMediaFile', () => { it('should accept valid JPEG file', () => { expect(() => { action['validateMediaFile']({ filename: 'test.jpg', mimeType: 'image/jpeg', data: Buffer.from('test'), size: 1024, }); }).not.toThrow(); }); it('should accept valid PNG file', () => { expect(() => { action['validateMediaFile']({ filename: 'test.png', mimeType: 'image/png', data: Buffer.from('test'), size: 1024, }); }).not.toThrow(); }); it('should reject unsupported mime types', () => { expect(() => { action['validateMediaFile']({ filename: 'test.bmp', mimeType: 'image/bmp', data: Buffer.from('test'), size: 1024, }); }).toThrow('Unsupported media type'); }); it('should reject files exceeding size limits', () => { expect(() => { action['validateMediaFile']({ filename: 'test.jpg', mimeType: 'image/jpeg', data: Buffer.from('test'), size: 10 * 1024 * 1024, // 10MB, exceeds 5MB limit }); }).toThrow('File size exceeds limit'); }); }); describe('parseRateLimitHeaders', () => { it('should parse standard rate limit headers', () => { const headers = { 'x-rate-limit-remaining': '99', 'x-rate-limit-reset': '1718444400', 'x-rate-limit-limit': '100', }; const result = action['parseRateLimitHeaders'](headers); expect(result).not.toBeNull(); expect(result.remaining).toBe(99); expect(result.limit).toBe(100); expect(result.reset).toBeInstanceOf(Date); }); it('should parse ratelimit variant headers', () => { const headers = { 'x-ratelimit-remaining': '50', 'x-ratelimit-reset': '1718444400', 'x-ratelimit-limit': '200', }; const result = action['parseRateLimitHeaders'](headers); expect(result).not.toBeNull(); expect(result.remaining).toBe(50); expect(result.limit).toBe(200); }); it('should return null when headers are missing', () => { const result = action['parseRateLimitHeaders']({}); expect(result).toBeNull(); }); }); describe('formatDate', () => { it('should format Date object to ISO string', () => { const date = new Date('2024-06-15T10:30:00Z'); expect(action['formatDate'](date)).toBe('2024-06-15T10:30:00.000Z'); }); it('should format date string to ISO string', () => { const result = action['formatDate']('2024-06-15T10:30:00Z'); expect(result).toBe('2024-06-15T10:30:00.000Z'); }); }); describe('parseDate', () => { it('should parse ISO date string', () => { const result = action['parseDate']('2024-06-15T10:30:00Z'); expect(result.toISOString()).toBe('2024-06-15T10:30:00.000Z'); }); }); describe('getParamValue', () => { it('should find param by name', () => { const params = [{ Name: 'ProfileID', Value: 'p1', Type: 'Input' }]; expect(action['getParamValue'](params, 'ProfileID')).toBe('p1'); }); it('should return undefined for missing param', () => { expect(action['getParamValue']([], 'Missing')).toBeUndefined(); }); }); }); describe('BufferBaseAction', () => { class TestBufferAction extends BufferBaseAction { async InternalRunAction() { return { Success: true, ResultCode: 'SUCCESS' }; } } let action; beforeEach(() => { action = new TestBufferAction(); }); describe('platformName', () => { it('should return Buffer', () => { expect(action['platformName']).toBe('Buffer'); }); }); describe('apiBaseUrl', () => { it('should return Buffer GraphQL API URL', () => { expect(action['apiBaseUrl']).toBe('https://api.buffer.com'); }); }); describe('extractHashtags', () => { it('should extract hashtags from content', () => { const result = action['extractHashtags']('Hello #world #test post'); expect(result).toEqual(['world', 'test']); }); it('should return empty array for no hashtags', () => { const result = action['extractHashtags']('Hello world'); expect(result).toEqual([]); }); it('should lowercase hashtags', () => { const result = action['extractHashtags']('#Hello #WORLD'); expect(result).toEqual(['hello', 'world']); }); }); describe('normalizePost', () => { const makeBufferPost = (overrides = {}) => ({ id: 'post1', text: 'Hello Buffer!', status: 'sent', dueAt: null, sentAt: '2024-06-15T10:00:00Z', createdAt: '2024-06-15T09:00:00Z', updatedAt: '2024-06-15T10:00:00Z', channelId: 'ch1', channelService: 'twitter', schedulingType: 'automatic_publishing', via: 'api', assets: null, tags: [], ...overrides, }); it('should normalize GraphQL post to common SocialPost format', () => { const post = makeBufferPost({ assets: { images: [{ url: 'https://example.com/pic.jpg' }], link: { url: 'https://example.com/link' }, }, }); const result = action['normalizePost'](post); expect(result.id).toBe('post1'); expect(result.platform).toBe('Buffer'); expect(result.profileId).toBe('ch1'); expect(result.content).toBe('Hello Buffer!'); expect(result.mediaUrls).toContain('https://example.com/pic.jpg'); expect(result.mediaUrls).toContain('https://example.com/link'); expect(result.publishedAt).toBeInstanceOf(Date); expect(result.publishedAt.toISOString()).toBe('2024-06-15T10:00:00.000Z'); }); it('should handle post with no assets', () => { const post = makeBufferPost(); const result = action['normalizePost'](post); expect(result.mediaUrls).toEqual([]); }); it('should handle post with scheduled date', () => { const post = makeBufferPost({ dueAt: '2024-06-16T12:00:00Z', status: 'buffer', }); const result = action['normalizePost'](post); expect(result.scheduledFor).toBeInstanceOf(Date); expect(result.scheduledFor.toISOString()).toBe('2024-06-16T12:00:00.000Z'); }); it('should use createdAt when sentAt is null', () => { const post = makeBufferPost({ sentAt: null }); const result = action['normalizePost'](post); expect(result.publishedAt.toISOString()).toBe('2024-06-15T09:00:00.000Z'); }); it('should include platform-specific data', () => { const post = makeBufferPost({ tags: [{ id: 't1', name: 'marketing' }], }); const result = action['normalizePost'](post); expect(result.platformSpecificData['channelService']).toBe('twitter'); expect(result.platformSpecificData['status']).toBe('sent'); expect(result.platformSpecificData['tags']).toEqual([{ id: 't1', name: 'marketing' }]); }); }); describe('normalizeAnalytics', () => { it('should normalize Buffer analytics', () => { const bufferStats = { reach: 1000, clicks: 50, favorites: 200, mentions: 30, retweets: 40, shares: 10, comments: 20, }; const result = action['normalizeAnalytics'](bufferStats); expect(result.impressions).toBe(1000); expect(result.clicks).toBe(50); expect(result.likes).toBe(200); expect(result.shares).toBe(10); expect(result.comments).toBe(20); expect(result.reach).toBe(1000); }); it('should handle empty stats', () => { const result = action['normalizeAnalytics']({}); expect(result.impressions).toBe(0); expect(result.clicks).toBe(0); expect(result.likes).toBe(0); expect(result.shares).toBe(0); }); }); describe('mapBufferError', () => { it('should map BufferGraphQLError with UNAUTHORIZED to INVALID_TOKEN', () => { const error = new BufferGraphQLError('Unauthorized', { code: 'UNAUTHORIZED' }); expect(action['mapBufferError'](error)).toBe('INVALID_TOKEN'); }); it('should map BufferGraphQLError with NOT_FOUND to POST_NOT_FOUND', () => { const error = new BufferGraphQLError('Not found', { code: 'NOT_FOUND' }); expect(action['mapBufferError'](error)).toBe('POST_NOT_FOUND'); }); it('should default to PLATFORM_ERROR for unknown errors', () => { expect(action['mapBufferError'](new Error('something'))).toBe('PLATFORM_ERROR'); }); it('should return PLATFORM_ERROR for non-axios non-graphql errors', () => { expect(action['mapBufferError']('string error')).toBe('PLATFORM_ERROR'); }); }); describe('uploadSingleMedia', () => { it('should throw explaining media upload is not supported', async () => { await expect(action['uploadSingleMedia']({ filename: 'test.jpg', mimeType: 'image/jpeg', data: Buffer.from('test'), size: 1024, })).rejects.toThrow('does not support standalone media upload'); }); }); }); describe('LinkedInBaseAction', () => { class TestLinkedInAction extends LinkedInBaseAction { async InternalRunAction() { return { Success: true, ResultCode: 'SUCCESS' }; } } let action; beforeEach(() => { action = new TestLinkedInAction(); }); describe('platformName', () => { it('should return LinkedIn', () => { expect(action['platformName']).toBe('LinkedIn'); }); }); describe('apiBaseUrl', () => { it('should return LinkedIn API URL', () => { expect(action['apiBaseUrl']).toBe('https://api.linkedin.com/v2'); }); }); describe('normalizeAnalytics', () => { it('should normalize LinkedIn analytics', () => { const analytics = { totalShareStatistics: { impressionCount: 5000, clickCount: 200, engagement: 0.04, likeCount: 150, commentCount: 30, shareCount: 20, uniqueImpressionsCount: 4000, }, }; const result = action['normalizeAnalytics'](analytics); expect(result.impressions).toBe(5000); expect(result.clicks).toBe(200); expect(result.likes).toBe(150); expect(result.comments).toBe(30); expect(result.shares).toBe(20); expect(result.reach).toBe(4000); }); it('should handle missing statistics', () => { const result = action['normalizeAnalytics']({}); expect(result.impressions).toBe(0); expect(result.clicks).toBe(0); expect(result.likes).toBe(0); }); }); describe('handleLinkedInError', () => { it('should throw for 401 errors', () => { const error = { response: { status: 401, data: {} }, request: {}, message: 'Unauthorized', }; expect(() => action['handleLinkedInError'](error)).toThrow('Unauthorized'); }); it('should throw for 403 errors', () => { const error = { response: { status: 403, data: {} }, request: {}, message: 'Forbidden', }; expect(() => action['handleLinkedInError'](error)).toThrow('Forbidden'); }); it('should throw for 404 errors', () => { const error = { response: { status: 404, data: {} }, request: {}, message: 'Not Found', }; expect(() => action['handleLinkedInError'](error)).toThrow('Not Found'); }); it('should throw for 429 errors', () => { const error = { response: { status: 429, data: {} }, request: {}, message: 'Too Many Requests', }; expect(() => action['handleLinkedInError'](error)).toThrow('Rate Limit Exceeded'); }); it('should throw for network errors', () => { const error = { request: {}, message: 'Network Error', }; expect(() => action['handleLinkedInError'](error)).toThrow('Network Error'); }); it('should throw for request setup errors', () => { const error = { message: 'Request setup failed', }; expect(() => action['handleLinkedInError'](error)).toThrow('Request Error'); }); }); describe('parseRateLimitHeaders', () => { it('should parse LinkedIn-specific rate limit headers', () => { const headers = { 'x-app-rate-limit-remaining': '80', 'x-app-rate-limit-limit': '100', 'x-member-rate-limit-remaining': '90', 'x-member-rate-limit-limit': '100', }; const result = action['parseRateLimitHeaders'](headers); expect(result).not.toBeNull(); expect(result.remaining).toBe(80); // min(80, 90) expect(result.limit).toBe(100); // min(100, 100) expect(result.reset).toBeInstanceOf(Date); }); it('should use more restrictive limit', () => { const headers = { 'x-app-rate-limit-remaining': '50', 'x-app-rate-limit-limit': '200', 'x-member-rate-limit-remaining': '10', 'x-member-rate-limit-limit': '100', }; const result = action['parseRateLimitHeaders'](headers); expect(result).not.toBeNull(); expect(result.remaining).toBe(10); // min(50, 10) expect(result.limit).toBe(100); // min(200, 100) }); it('should return null when headers are missing', () => { const result = action['parseRateLimitHeaders']({}); expect(result).toBeNull(); }); }); describe('normalizePost', () => { it('should normalize LinkedIn share to common format', () => { const linkedInShare = { id: 'share1', author: 'urn:li:person:abc', created: { actor: 'urn:li:person:abc', time: 1718444400000 }, firstPublishedAt: 1718444400000, lifecycleState: 'PUBLISHED', specificContent: { 'com.linkedin.ugc.ShareContent': { shareCommentary: { text: 'Hello LinkedIn!' }, shareMediaCategory: 'NONE', }, }, visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC', }, }; const result = action['normalizePost'](linkedInShare); expect(result.id).toBe('share1'); expect(result.platform).toBe('LinkedIn'); expect(result.profileId).toBe('urn:li:person:abc'); expect(result.content).toBe('Hello LinkedIn!'); expect(result.publishedAt).toBeInstanceOf(Date); }); it('should extract media URLs from share', () => { const linkedInShare = { id: 'share2', author: 'urn:li:person:abc', created: { actor: 'urn:li:person:abc', time: 1718444400000 }, lifecycleState: 'PUBLISHED', specificContent: { 'com.linkedin.ugc.ShareContent': { shareCommentary: { text: 'Post with media' }, shareMediaCategory: 'IMAGE', media: [{ status: 'READY', media: 'urn:li:digitalmediaAsset:123' }], }, }, visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC', }, }; const result = action['normalizePost'](linkedInShare); expect(result.mediaUrls).toContain('urn:li:digitalmediaAsset:123'); }); }); describe('validateMediaFile', () => { it('should accept supported image types', () => { expect(() => { action['validateMediaFile']({ filename: 'test.jpg', mimeType: 'image/jpeg', data: Buffer.from('test'), size: 1024, }); }).not.toThrow(); }); it('should accept webp format', () => { expect(() => { action['validateMediaFile']({ filename: 'test.webp', mimeType: 'image/webp', data: Buffer.from('test'), size: 1024, }); }).not.toThrow(); }); it('should reject unsupported types', () => { expect(() => { action['validateMediaFile']({ filename: 'test.bmp', mimeType: 'image/bmp', data: Buffer.from('test'), size: 1024, }); }).toThrow('Unsupported media type'); }); it('should reject files over 10MB', () => { expect(() => { action['validateMediaFile']({ filename: 'test.jpg', mimeType: 'image/jpeg', data: Buffer.from('test'), size: 11 * 1024 * 1024, }); }).toThrow('File size exceeds limit'); }); }); }); //# sourceMappingURL=social.test.js.map