'use strict';
const { shallowMount } = require( 'vue-test-utils' );
const UserCardBody = require( 'ext.checkUser.userInfoCard/modules/ext.checkUser.userInfoCard/components/UserCardBody.vue' );
QUnit.module( 'ext.checkUser.userInfoCard.UserCardBody', QUnit.newMwEnvironment( {
beforeEach: function () {
this.sandbox.stub( mw, 'msg' ).callsFake( ( key, ...args ) => {
let returnValue = '(' + key;
if ( args.length !== 0 ) {
returnValue += ': ' + args.join( ', ' );
}
return returnValue + ')';
} );
// Stub mw.Title.makeTitle
this.sandbox.stub( mw.Title, 'makeTitle' ).callsFake( ( namespace, title ) => ( {
getUrl: ( query ) => {
let url = `/${ namespace }/${ title }`;
if ( query ) {
const params = Object.entries( query )
.map( ( [ key, value ] ) => `${ key }=${ value }` )
.join( '&' );
url += `?${ params }`;
}
return url;
},
getPrefixedText: () => {
const nsText = namespace === 0 ? '' : `Namespace${ namespace }:`;
return `${ nsText }${ title }`;
}
} ) );
// Force permission configs
mw.config.set( 'wgCheckUserCanViewCheckUserLog', true );
mw.config.set( 'wgCheckUserCanBlock', true );
mw.config.set( 'wgCheckUserGEUserImpactMaxEdits', 1000 );
mw.config.set( 'CheckUserEnableUserInfoCardInstrumentation', false );
}
} ) );
// Sample data for testing
const sampleRecentEdits = [
{ date: new Date( '2025-01-01' ), count: 5 },
{ date: new Date( '2025-01-02' ), count: 3 },
{ date: new Date( '2025-01-03' ), count: 7 }
];
// Reusable mount helper
function mountComponent( props = {} ) {
return shallowMount( UserCardBody, {
propsData: {
userId: '123',
username: 'TestUser',
gender: 'female',
joinedDate: '2020-01-01',
joinedRelative: '5 years ago',
isRegisteredWithUnknownTime: false,
activeBlocks: 2,
pastBlocks: 3,
globalEdits: 1000,
localEdits: 500,
localEditsReverted: 10,
newArticles: 20,
thanksReceived: 30,
thanksSent: 15,
checks: 5,
lastChecked: '2024-12-31',
lastEditTimestamp: '',
activeWikis: {},
recentLocalEdits: [],
hasEditInLast60Days: false,
totalLocalEdits: 500,
specialCentralAuthUrl: 'https://example.com/wiki/Special:CentralAuth/TestUser',
hasIpRevealInfo: true,
numberOfIpReveals: 2,
ipRevealLastCheck: '20250102030408',
tempAccountsOnIpCount: [ 0, 0 ],
...props
}
} );
}
function findRowByLabel( wrapper, labelMsg ) {
const infoRows = wrapper.findAllComponents( { name: 'InfoRowWithLinks' } );
return infoRows.find( ( row ) => row.props( 'messageKey' ) === labelMsg );
}
QUnit.test( 'renders correctly with required props', ( assert ) => {
const wrapper = mountComponent();
assert.true( wrapper.exists(), 'Component renders' );
assert.true(
wrapper.classes().includes( 'ext-checkuser-userinfocard-body' ),
'Body has correct class'
);
} );
QUnit.test( 'displays joined date information correctly', ( assert ) => {
const wrapper = mountComponent();
const joinedParagraph = wrapper.find( '.ext-checkuser-userinfocard-joined' );
assert.true( joinedParagraph.exists(), 'Joined paragraph exists' );
assert.strictEqual(
joinedParagraph.text(),
'(checkuser-userinfocard-joined: 2020-01-01, 5 years ago, female)',
'Joined paragraph displays correct information'
);
} );
QUnit.test( 'displays registration date unknown information correctly', ( assert ) => {
const wrapper = mountComponent( { isRegisteredWithUnknownTime: true } );
const joinedParagraph = wrapper.find( '.ext-checkuser-userinfocard-joined' );
assert.true( joinedParagraph.exists(), 'Joined paragraph exists' );
assert.strictEqual(
joinedParagraph.text(),
'(checkuser-userinfocard-joined-unknowndate: female)',
'Joined paragraph displays unknown date indicator'
);
} );
QUnit.test( 'renders correct number of InfoRowWithLinks components with all permissions', ( assert ) => {
const wrapper = mountComponent( { canAccessTemporaryAccountIpAddresses: true } );
const infoRows = wrapper.findAllComponents( { name: 'InfoRowWithLinks' } );
assert.strictEqual( infoRows.length, 9, 'Renders 9 InfoRowWithLinks components when all permissions are granted' );
} );
QUnit.test( 'renders correct number of InfoRowWithLinks components with no permissions', ( assert ) => {
mw.config.set( 'wgCheckUserCanViewCheckUserLog', false );
mw.config.set( 'wgCheckUserCanBlock', false );
// FIXME: Better test to handle the canAccessTemporaryAccountIpAddresses case, which is about
// both permissions of viewing and viewed user
const wrapper = mountComponent( {
canAccessTemporaryAccountIpAddresses: false,
hasIpRevealInfo: false
} );
const infoRows = wrapper.findAllComponents( { name: 'InfoRowWithLinks' } );
assert.strictEqual( infoRows.length, 5, 'Renders 5 InfoRowWithLinks components when no permissions are granted' );
} );
QUnit.test.each( 'should cap thanks and new articles counts when at or above configured limit', {
'below limit': [
{ newArticles: 800, thanksReceived: 700, thanksSent: 600 },
'800',
'700',
'600'
],
'new articles count at limit': [
{ newArticles: 1000, thanksReceived: 700, thanksSent: 600 },
'(checkuser-userinfocard-count-exceeds-max-to-display: 1,000)',
'700',
'600'
],
'thanks received count at limit': [
{ newArticles: 800, thanksReceived: 1000, thanksSent: 600 },
'800',
'(checkuser-userinfocard-count-exceeds-max-to-display: 1,000)',
'600'
],
'thanks sent count at limit': [
{ newArticles: 800, thanksReceived: 700, thanksSent: 1000 },
'800',
'700',
'(checkuser-userinfocard-count-exceeds-max-to-display: 1,000)'
]
}, ( assert, [ props, expectedNewArticlesCount, expectedThanksReceivedCount, expectedThanksSentCount ] ) => {
const wrapper = mountComponent( props );
const newArticlesRow = findRowByLabel( wrapper, 'checkuser-userinfocard-new-articles' );
const thanksRow = findRowByLabel( wrapper, 'checkuser-userinfocard-thanks' );
assert.strictEqual(
newArticlesRow.props( 'mainValue' ),
expectedNewArticlesCount,
'New article count is correct'
);
assert.strictEqual(
thanksRow.props( 'mainValue' ),
expectedThanksReceivedCount,
'Thanks received count is correct'
);
assert.strictEqual(
thanksRow.props( 'suffixValue' ),
expectedThanksSentCount,
'Thanks sent count is correct'
);
} );
QUnit.test( 'passes correct props to active blocks row', ( assert ) => {
const wrapper = mountComponent();
// Find the active blocks row by its message key
const activeBlocksRow = findRowByLabel( wrapper, 'checkuser-userinfocard-active-blocks-from-all-wikis' );
assert.true( activeBlocksRow !== undefined, 'Active blocks row exists' );
assert.strictEqual(
activeBlocksRow.props( 'messageKey' ),
'checkuser-userinfocard-active-blocks-from-all-wikis',
'Blocks row has correct message key'
);
assert.strictEqual(
activeBlocksRow.props( 'mainValue' ),
'2',
'Blocks row has correct main value (converted to string)'
);
assert.strictEqual(
activeBlocksRow.props( 'mainLink' ),
'https://example.com/wiki/Special:CentralAuth/TestUser',
'Active blocks row has correct main link'
);
assert.strictEqual(
activeBlocksRow.props( 'mainLinkLogId' ),
'active_blocks',
'Active blocks row has correct main link log ID'
);
assert.strictEqual(
activeBlocksRow.props( 'suffixValue' ),
'',
'Active blocks row has no suffix value'
);
assert.strictEqual(
activeBlocksRow.props( 'suffixLink' ),
'',
'Active blocks row has no suffix link'
);
assert.strictEqual(
activeBlocksRow.props( 'suffixLinkLogId' ),
'',
'Active blocks row has no suffix link log ID'
);
} );
QUnit.test( 'passes correct props to past blocks row', ( assert ) => {
const wrapper = mountComponent();
// Find the past blocks row by its message key
const infoRows = wrapper.findAllComponents( { name: 'InfoRowWithLinks' } );
const pastBlocksRow = infoRows.find( ( row ) => row.props( 'messageKey' ) === 'checkuser-userinfocard-past-blocks' );
assert.true( pastBlocksRow !== undefined, 'Past blocks row exists' );
assert.strictEqual(
pastBlocksRow.props( 'messageKey' ),
'checkuser-userinfocard-past-blocks',
'Past blocks row has correct message key'
);
assert.strictEqual(
pastBlocksRow.props( 'mainValue' ),
'3',
'Past blocks row has correct main value (converted to string)'
);
assert.strictEqual(
pastBlocksRow.props( 'mainLink' ),
'/-1/Log/block?page=TestUser',
'Past blocks row has correct main link'
);
assert.strictEqual(
pastBlocksRow.props( 'mainLinkLogId' ),
'past_blocks',
'Past blocks row has correct main link log ID'
);
assert.strictEqual(
pastBlocksRow.props( 'suffixValue' ),
'',
'Past blocks row has no suffix value'
);
assert.strictEqual(
pastBlocksRow.props( 'suffixLink' ),
'',
'Past blocks row has no suffix link'
);
assert.strictEqual(
pastBlocksRow.props( 'suffixLinkLogId' ),
'',
'Past blocks row has no suffix link log ID'
);
} );
QUnit.test( 'does not render past blocks row when permission is not granted', ( assert ) => {
mw.config.set( 'wgCheckUserCanBlock', false );
const wrapper = mountComponent();
const infoRows = wrapper.findAllComponents( { name: 'InfoRowWithLinks' } );
const pastBlocksRow = infoRows.find( ( row ) => row.props( 'messageKey' ).includes( 'checkuser-userinfocard-past-blocks' ) );
assert.strictEqual( pastBlocksRow, undefined, 'Past blocks row does not exist when permission is not granted' );
const activeBlocksRow = infoRows.find( ( row ) => row.props( 'messageKey' ).includes( 'checkuser-userinfocard-active-blocks' ) );
assert.true( activeBlocksRow.exists(), 'Active blocks row exists regardless of permissions' );
} );
QUnit.test( 'does not render active blocks row or past blocks row when count is zero', ( assert ) => {
const wrapper = mountComponent( {
pastBlocks: 0,
activeBlocks: 0
} );
const infoRows = wrapper.findAllComponents( { name: 'InfoRowWithLinks' } );
const pastBlocksRow = infoRows.find( ( row ) => row.props( 'messageKey' ).includes( 'checkuser-userinfocard-past-blocks' ) );
assert.strictEqual( pastBlocksRow, undefined, 'Past blocks row does not exist when count is zero' );
const activeBlocksRow = infoRows.find( ( row ) => row.props( 'messageKey' ).includes( 'checkuser-userinfocard-active-blocks' ) );
assert.strictEqual( activeBlocksRow, undefined, 'Active blocks row does not exist when count is zero' );
} );
QUnit.test( 'hides data from GrowthExperiments if unavailable', ( assert ) => {
// These properties are not set in the response if GE is not enabled,
// so set them to undefined and not null
const wrapper = mountComponent( {
localEditsReverted: undefined,
newArticles: undefined,
thanksReceived: undefined,
thanksSent: undefined
} );
const localEditsWithRevertsRow = findRowByLabel( wrapper, 'checkuser-userinfocard-local-edits' );
assert.strictEqual( undefined, localEditsWithRevertsRow, 'Local edits with reverts row is not rendered' );
const localEditsNoRevertsRow = findRowByLabel( wrapper, 'checkuser-userinfocard-local-edits-reverts-unknown' );
assert.notStrictEqual( undefined, localEditsNoRevertsRow, 'Local edits no reverts row is not rendered' );
const newArticlesRow = findRowByLabel( wrapper, 'checkuser-userinfocard-new-articles' );
assert.strictEqual( undefined, newArticlesRow, 'New articles row is not rendered' );
const thanksRow = findRowByLabel( wrapper, 'checkuser-userinfocard-thanks' );
assert.strictEqual( undefined, thanksRow, 'Thanks row is not rendered' );
} );
QUnit.test( 'renders user groups', ( assert ) => {
const wrapper = mountComponent( { groups: 'Groups: Administrators, Check users' } );
const groupsParagraph = wrapper.find( '.ext-checkuser-userinfocard-groups' );
assert.true( groupsParagraph.exists() );
const paragraphText = groupsParagraph.text();
assert.true( paragraphText.includes( 'Administrators, Check users' ) );
} );
QUnit.test( 'renders global user groups', ( assert ) => {
const wrapper = mountComponent( { globalGroups: 'Global groups: Stewards' } );
const globalGroupsParagraph = wrapper.find( '.ext-checkuser-userinfocard-global-groups' );
assert.true( globalGroupsParagraph.exists() );
const paragraphText = globalGroupsParagraph.text();
assert.true( paragraphText.includes( 'Stewards' ) );
} );
QUnit.test( 'does not render active wikis paragraph when activeWikis is empty', ( assert ) => {
const wrapper = mountComponent();
const activeWikisParagraph = wrapper.find( '.ext-checkuser-userinfocard-active-wikis' );
assert.false( activeWikisParagraph.exists(), 'Active wikis paragraph does not exist when activeWikis is empty' );
} );
QUnit.test( 'renders active wikis paragraph when activeWikis is not empty', ( assert ) => {
const activeWikisObj = {
enwiki: 'https://en.wikipedia.org',
dewiki: 'https://de.wikipedia.org',
frwiki: 'https://fr.wikipedia.org'
};
const wrapper = mountComponent( { activeWikis: activeWikisObj } );
const activeWikisParagraph = wrapper.find( '.ext-checkuser-userinfocard-active-wikis' );
assert.true( activeWikisParagraph.exists(), 'Active wikis paragraph exists when activeWikis is not empty' );
// Check that the paragraph contains the wiki IDs
const paragraphText = activeWikisParagraph.text();
assert.true( paragraphText.includes( 'enwiki' ), 'Paragraph includes enwiki' );
assert.true( paragraphText.includes( 'dewiki' ), 'Paragraph includes dewiki' );
assert.true( paragraphText.includes( 'frwiki' ), 'Paragraph includes frwiki' );
} );
QUnit.test( 'renders active wikis as links with correct URLs', ( assert ) => {
const activeWikisObj = {
enwiki: 'https://en.wikipedia.org',
dewiki: 'https://de.wikipedia.org'
};
const wrapper = mountComponent( { activeWikis: activeWikisObj } );
const wikiLinks = wrapper.findAll( '.ext-checkuser-userinfocard-active-wikis a' );
assert.strictEqual( wikiLinks.length, 2, 'Renders correct number of wiki links' );
// Check first link
assert.strictEqual( wikiLinks[ 0 ].text(), 'enwiki', 'First link has correct text' );
assert.strictEqual( wikiLinks[ 0 ].attributes( 'href' ), 'https://en.wikipedia.org', 'First link has correct URL' );
// Check second link
assert.strictEqual( wikiLinks[ 1 ].text(), 'dewiki', 'Second link has correct text' );
assert.strictEqual( wikiLinks[ 1 ].attributes( 'href' ), 'https://de.wikipedia.org', 'Second link has correct URL' );
} );
QUnit.test( 'renders UserActivityChart when recentLocalEdits is not empty', ( assert ) => {
const wrapper = mountComponent( {
recentLocalEdits: sampleRecentEdits,
hasEditInLast60Days: true
} );
const activityChart = wrapper.findComponent( { name: 'UserActivityChart' } );
assert.true( activityChart.exists(), 'UserActivityChart exists when hasEditInLast60Days is true' );
assert.strictEqual(
activityChart.props( 'username' ),
'TestUser',
'UserActivityChart has correct username'
);
assert.deepEqual(
activityChart.props( 'recentLocalEdits' ),
sampleRecentEdits,
'UserActivityChart has correct recentLocalEdits'
);
assert.strictEqual(
activityChart.props( 'totalLocalEdits' ),
500,
'UserActivityChart has correct totalLocalEdits'
);
} );
QUnit.test( 'does not render UserActivityChart when hasEditInLast60Days is false', ( assert ) => {
const wrapper = mountComponent( {
hasEditInLast60Days: false
} );
const activityChart = wrapper.findComponent( { name: 'UserActivityChart' } );
assert.false( activityChart.exists(), 'UserActivityChart exists when hasEditInLast60Days is false' );
} );
QUnit.test( 'setup function returns correct values with all permissions', ( assert ) => {
const wrapper = mountComponent( { canAccessTemporaryAccountIpAddresses: true } );
assert.strictEqual(
wrapper.vm.joined,
'(checkuser-userinfocard-joined: 2020-01-01, 5 years ago, female)',
'joined is set correctly'
);
assert.strictEqual(
wrapper.vm.activeWikisLabel,
'(checkuser-userinfocard-active-wikis-label)',
'activeWikisLabel is set correctly'
);
assert.strictEqual(
wrapper.vm.infoRows.length,
9,
'infoRows has correct length with all permissions'
);
const ipRevealInfo = wrapper.vm.infoRows.filter(
( r ) => r.messageKey ===
'checkuser-userinfocard-ip-revealed-count'
);
assert.strictEqual(
ipRevealInfo.length,
1,
'infoRows contains IP Reveal info'
);
assert.strictEqual(
ipRevealInfo[ 0 ].mainValue,
'2',
'infoRows contains the expected IP Reveal count'
);
assert.strictEqual(
ipRevealInfo[ 0 ].suffixValue,
'20250102030408',
'infoRows contains the expected last IP Reveal timestamp'
);
} );
QUnit.test( 'setup function returns correct values with no permissions', ( assert ) => {
mw.config.set( 'wgCheckUserCanViewCheckUserLog', false );
mw.config.set( 'wgCheckUserCanBlock', false );
// FIXME: Better test to handle the canAccessTemporaryAccountIpAddresses case, which is about
// both permissions of viewing and viewed user
const wrapper = mountComponent( {
canAccessTemporaryAccountIpAddresses: false,
hasIpRevealInfo: false
} );
assert.strictEqual(
wrapper.vm.infoRows.length,
5,
'infoRows has correct length with no permissions'
);
} );
QUnit.test( 'activeWikisList computed property transforms object to array correctly', ( assert ) => {
const activeWikisObj = {
enwiki: 'https://en.wikipedia.org',
dewiki: 'https://de.wikipedia.org'
};
const wrapper = mountComponent( { activeWikis: activeWikisObj } );
const activeWikisList = wrapper.vm.activeWikisList;
assert.strictEqual( activeWikisList.length, 2, 'activeWikisList has correct length' );
// Check that the array contains objects with wikiId and url properties
assert.deepEqual(
activeWikisList[ 0 ],
{ wikiId: 'enwiki', url: 'https://en.wikipedia.org' },
'First item in activeWikisList has correct structure'
);
assert.deepEqual(
activeWikisList[ 1 ],
{ wikiId: 'dewiki', url: 'https://de.wikipedia.org' },
'Second item in activeWikisList has correct structure'
);
} );
QUnit.test.each( 'should correctly display range, min, and max for temp accounts on ips count', {
min: [
{ tempAccountsOnIpCount: [ 0, 0 ], username: '~2025-1' },
'(checkuser-temporary-account-bucketcount-min: 0, 0)'
],
range: [
{ tempAccountsOnIpCount: [ 1, 2 ], username: '~2025-1' },
'(checkuser-temporary-account-bucketcount-range: 1, 2)'
],
max: [
{ tempAccountsOnIpCount: [ 11, 11 ], username: '~2025-1' },
'(checkuser-temporary-account-bucketcount-max: 11, 11)'
]
}, ( assert, [ props, expectedString ] ) => {
const wrapper = mountComponent( props );
const tempAccountCountRow = findRowByLabel( wrapper, 'checkuser-userinfocard-temporary-account-bucketcount' );
assert.strictEqual(
tempAccountCountRow.props( 'mainValue' ),
expectedString,
'Bucket expression is correct'
);
} );
QUnit.test( 'temporary accounts on ip count doesn\'t display for registered users', ( assert ) => {
const wrapper = mountComponent( { username: 'User 1' } );
assert.strictEqual(
findRowByLabel( wrapper, 'checkuser-userinfocard-temporary-account-bucketcount' ),
undefined
);
} );
// TODO: T386440 - Fix the test and remove the skip
// This test fails when running in conjunction with the other test components in this folder.
// When running this test file alone, this test is passing.
QUnit.test.skip( 'logs an event when onWikiLinkClick is called', function ( assert ) {
mw.config.set( 'CheckUserEnableUserInfoCardInstrumentation', true );
this.sandbox.stub( mw.user, 'sessionId' ).returns( 'test-session-id' );
this.sandbox.stub( mw.user, 'getId' ).returns( 123 );
const submitInteractionStub = this.sandbox.stub();
const instrumentStub = { submitInteraction: submitInteractionStub };
this.sandbox.stub( mw.eventLog, 'newInstrument' ).returns( instrumentStub );
const activeWikisObj = {
enwiki: 'https://en.wikipedia.org',
dewiki: 'https://de.wikipedia.org'
};
const wrapper = mountComponent( { activeWikis: activeWikisObj } );
wrapper.vm.onWikiLinkClick( 'enwiki' );
assert.strictEqual( submitInteractionStub.callCount, 1, 'submitInteraction is called once' );
assert.strictEqual(
submitInteractionStub.firstCall.args[ 0 ],
'link_click',
'First argument is "link_click"'
);
const interactionData = submitInteractionStub.firstCall.args[ 1 ];
assert.strictEqual(
interactionData.funnel_entry_token,
'test-session-id',
'Includes session token in interaction data'
);
assert.strictEqual(
interactionData.action_subtype,
'active_wiki',
'Includes correct subType in interaction data'
);
assert.strictEqual(
interactionData.action_source,
'card_body',
'Includes correct source in interaction data'
);
assert.strictEqual(
interactionData.action_context,
'enwiki',
'Includes correct action_context (wiki ID) in interaction data'
);
} );