@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
598 lines • 23.3 kB
JavaScript
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