Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frontend/src/__tests__/api/players.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
giveItem,
getAllPlayerMetadata,
updatePlayerMetadata,
setPlayerTier,
} from '@/api/players'

describe('Players API', () => {
Expand Down Expand Up @@ -96,4 +97,17 @@ describe('Players API', () => {
await updatePlayerMetadata('abc', { nameColor: '#00ff00' })
expect(mockPut).toHaveBeenCalledWith('/api/players/metadata/abc', { nameColor: '#00ff00' })
})

it('setPlayerTier calls PUT /api/players/metadata/{id}/tier with the tier', async () => {
mockPut.mockResolvedValue({ data: { data: { playerId: 'abc', vipTier: 'VIP' } } })
const result = await setPlayerTier('abc', 'VIP')
expect(mockPut).toHaveBeenCalledWith('/api/players/metadata/abc/tier', { tier: 'VIP' })
expect(result.vipTier).toBe('VIP')
})

it('setPlayerTier sends null to clear the tier and encodes the id', async () => {
mockPut.mockResolvedValue({ data: { data: { playerId: 'EOS_x/y', vipTier: null } } })
await setPlayerTier('EOS_x/y', null)
expect(mockPut).toHaveBeenCalledWith('/api/players/metadata/EOS_x%2Fy/tier', { tier: null })
})
})
56 changes: 56 additions & 0 deletions frontend/src/__tests__/api/vipperks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()
const mockPut = vi.fn()

vi.mock('@/api/client', () => ({
default: {
get: (...args: any[]) => mockGet(...args),
put: (...args: any[]) => mockPut(...args),
},
}))

import { getVipPerksSettings, updateVipPerksSettings, getVipTiers } from '@/api/vipperks'
import type { VipPerksSettings } from '@/types'

const sampleSettings: VipPerksSettings = {
enabled: true,
tiers: ['VIP', 'VIP+'],
firstLoginPackEnabled: true,
firstLoginTemplateName: 'starter',
firstLoginMessage: 'hi {player}',
tierGifts: [{ tier: 'VIP', templateName: 'weekly-crate', period: 'weekly' }],
}

describe('VipPerks API', () => {
beforeEach(() => {
mockGet.mockReset()
mockPut.mockReset()
})

it('getVipPerksSettings calls GET /api/vipperks/settings', async () => {
mockGet.mockResolvedValue({ data: { data: sampleSettings } })
const result = await getVipPerksSettings()
expect(mockGet).toHaveBeenCalledWith('/api/vipperks/settings')
expect(result).toEqual(sampleSettings)
})

it('updateVipPerksSettings calls PUT /api/vipperks/settings with the body', async () => {
mockPut.mockResolvedValue({ data: {} })
await updateVipPerksSettings(sampleSettings)
expect(mockPut).toHaveBeenCalledWith('/api/vipperks/settings', sampleSettings)
})

it('getVipTiers returns the tier list', async () => {
mockGet.mockResolvedValue({ data: { data: ['VIP', 'VIP+'] } })
const result = await getVipTiers()
expect(mockGet).toHaveBeenCalledWith('/api/vipperks/tiers')
expect(result).toEqual(['VIP', 'VIP+'])
})

it('getVipTiers returns [] when data is null', async () => {
mockGet.mockResolvedValue({ data: { data: null } })
const result = await getVipTiers()
expect(result).toEqual([])
})
})
105 changes: 105 additions & 0 deletions frontend/src/__tests__/components/PlayerEditDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import PlayerEditDialog from '@/components/PlayerEditDialog.vue'

const mockUpdatePlayerMetadata = vi.fn()
const mockSetAdminLevel = vi.fn()
const mockSetPlayerTier = vi.fn()
const mockGetVipTiers = vi.fn()

vi.mock('@/api/players', () => ({
updatePlayerMetadata: (...a: any[]) => mockUpdatePlayerMetadata(...a),
setAdminLevel: (...a: any[]) => mockSetAdminLevel(...a),
setPlayerTier: (...a: any[]) => mockSetPlayerTier(...a),
}))
vi.mock('@/api/vipperks', () => ({
getVipTiers: () => mockGetVipTiers(),
}))

// Store returns no cached metadata by default → tier starts as '' (pleb).
const mockGetMetadata = vi.fn(() => undefined)
vi.mock('@/stores/players', () => ({
usePlayersStore: () => ({ getMetadata: mockGetMetadata }),
}))

const player = {
playerId: 'EOS_abc',
playerName: 'Alice',
entityId: 42,
isAdmin: false,
adminLevel: 1000,
}

function mountDialog() {
return mount(PlayerEditDialog, {
props: { player, visible: false },
global: {
plugins: [createPinia(), [PrimeVue, { theme: 'none' }]],
stubs: {
Dialog: { template: '<div><slot /><slot name="footer" /></div>', props: ['visible', 'header', 'modal'] },
Button: { template: '<button @click="$emit(\'click\')">{{ label }}</button>', props: ['label', 'icon', 'loading', 'text', 'severity', 'size'] },
InputText: { template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />', props: ['modelValue', 'placeholder'] },
Textarea: { template: '<textarea />', props: ['modelValue', 'rows', 'placeholder'] },
ColorPicker: { template: '<input />', props: ['modelValue'] },
Select: {
template: '<select :data-testid="$attrs[\'data-testid\']" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
},
},
},
})
}

describe('PlayerEditDialog — VIP tier', () => {
beforeEach(() => {
mockUpdatePlayerMetadata.mockReset().mockResolvedValue({})
mockSetAdminLevel.mockReset().mockResolvedValue(undefined)
mockSetPlayerTier.mockReset().mockResolvedValue({})
mockGetVipTiers.mockReset().mockResolvedValue(['VIP', 'VIP+'])
mockGetMetadata.mockReset().mockReturnValue(undefined)
})

it('loads available tiers when opened', async () => {
const wrapper = mountDialog()
await wrapper.setProps({ visible: true })
await flushPromises()
expect(mockGetVipTiers).toHaveBeenCalled()
})

it('seeds the tier from cached metadata', async () => {
mockGetMetadata.mockReturnValue({ playerId: 'EOS_abc', vipTier: 'VIP+' })
const wrapper = mountDialog()
await wrapper.setProps({ visible: true })
await flushPromises()
// Save and assert the seeded tier is sent back.
await wrapper.find('button:last-of-type').trigger('click')
await flushPromises()
expect(mockSetPlayerTier).toHaveBeenCalledWith('EOS_abc', 'VIP+')
})

it('saves metadata and tier together, without touching admin level for a normal player', async () => {
const wrapper = mountDialog()
await wrapper.setProps({ visible: true })
await flushPromises()

await wrapper.find('button:last-of-type').trigger('click')
await flushPromises()

expect(mockUpdatePlayerMetadata).toHaveBeenCalledWith('EOS_abc', expect.any(Object))
expect(mockSetPlayerTier).toHaveBeenCalledWith('EOS_abc', null) // '' → null
expect(mockSetAdminLevel).not.toHaveBeenCalled()
})

it('falls back to no tiers when the lookup fails', async () => {
mockGetVipTiers.mockRejectedValue(new Error('no backend'))
const wrapper = mountDialog()
await wrapper.setProps({ visible: true })
await flushPromises()
// Still saves cleanly with an empty tier.
await wrapper.find('button:last-of-type').trigger('click')
await flushPromises()
expect(mockSetPlayerTier).toHaveBeenCalledWith('EOS_abc', null)
})
})
122 changes: 122 additions & 0 deletions frontend/src/__tests__/components/SettingsViewVipPerks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Focused coverage for the VIP Perks tab of SettingsView. We render ONLY that
// panel (the TabPanel stub renders its slot solely for value === "11"), so the
// other tabs' templates never execute — their fetch-on-mount calls are mocked
// to resolve with harmless shapes purely so onMounted doesn't throw.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'

const mockGetVipPerks = vi.fn()
const mockUpdateVipPerks = vi.fn()

vi.mock('@/api/vipperks', () => ({
getVipPerksSettings: () => mockGetVipPerks(),
updateVipPerksSettings: (...a: any[]) => mockUpdateVipPerks(...a),
getVipTiers: () => Promise.resolve([]),
}))

// Every other tab's data source — resolve with shapes safe to assign.
vi.mock('@/api/auth', () => ({ changePassword: vi.fn() }))
vi.mock('@/api/users', () => ({
getUsers: () => Promise.resolve([]), createUser: vi.fn(), updateUser: vi.fn(),
resetUserPassword: vi.fn(), deleteUser: vi.fn(),
}))
vi.mock('@/api/settings', () => ({
getChatCommandSettings: () => Promise.resolve({}), updateChatCommandSettings: vi.fn(),
getPointsSettings: () => Promise.resolve({}), updatePointsSettings: vi.fn(),
getTeleportSettings: () => Promise.resolve({}), updateTeleportSettings: vi.fn(),
getStoreSettings: () => Promise.resolve({}), updateStoreSettings: vi.fn(),
}))
vi.mock('@/api/bloodmoonvote', () => ({ getVoteSettings: () => Promise.resolve({}), updateVoteSettings: vi.fn() }))
vi.mock('@/api/voterewards', () => ({
getVoteRewardsSettings: () => Promise.resolve({ enabled: false, providers: [] }),
updateVoteRewardsSettings: vi.fn(),
getVoteGrants: () => Promise.resolve([]),
}))
vi.mock('@/api/restart', () => ({ getRestartSettings: () => Promise.resolve({}), updateRestartSettings: vi.fn(), triggerRestartNow: vi.fn() }))
vi.mock('@/api/tickets', () => ({ getTicketSettings: () => Promise.resolve({}), updateTicketSettings: vi.fn() }))
vi.mock('@/api/discord', () => ({
getDiscordSettings: () => Promise.resolve({}), updateDiscordSettings: vi.fn(),
getDiscordStatus: () => Promise.resolve({ isConnected: false }), testDiscordConnection: vi.fn(),
}))
vi.mock('@/api/serverControl', () => ({ restartServer: vi.fn() }))
vi.mock('@/composables/usePermissions', () => ({ usePermissions: () => ({ isAdmin: { value: true } }) }))
vi.mock('primevue/useconfirm', () => ({ useConfirm: () => ({ require: vi.fn() }) }))

import SettingsView from '@/views/SettingsView.vue'

const sample = {
enabled: true,
tiers: ['VIP'],
firstLoginPackEnabled: false,
firstLoginTemplateName: '',
firstLoginMessage: '',
tierGifts: [{ tier: 'VIP', templateName: 'crate', period: 'weekly' }],
}

const slotStub = (name: string) => ({ name, template: '<div><slot /></div>' })

function mountView() {
return mount(SettingsView, {
global: {
plugins: [createPinia(), [PrimeVue, { theme: 'none' }]],
stubs: {
Tabs: slotStub('Tabs'),
TabList: slotStub('TabList'),
TabPanels: slotStub('TabPanels'),
Tab: { name: 'Tab', template: '<div><slot /></div>', props: ['value'] },
// Render a panel's content only for the VIP Perks tab.
TabPanel: { name: 'TabPanel', props: ['value'], template: '<div v-if="value === \'11\'"><slot /></div>' },
Card: { name: 'Card', template: '<div><slot name="title" /><slot name="subtitle" /><slot name="content" /></div>' },
// No explicit @click emit: the parent's @click is a native fall-through
// listener on the stub's root <button>, so emitting here too would fire
// the handler twice. Let trigger('click') hit the native listener once.
Button: { name: 'Button', props: ['label', 'icon'], template: '<button :data-icon="icon">{{ label }}</button>' },
InputText: { name: 'InputText', props: ['modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ToggleSwitch: { name: 'ToggleSwitch', props: ['modelValue'], template: '<input type="checkbox" />' },
Select: { name: 'Select', props: ['modelValue', 'options'], template: '<select />' },
// Unused-by-this-panel components — empty stubs are fine.
Message: true, InputNumber: true, DataTable: true, Column: true, Tag: true, Dialog: true,
},
},
})
}

describe('SettingsView — VIP Perks tab', () => {
beforeEach(() => {
mockGetVipPerks.mockReset().mockResolvedValue({ ...sample, tierGifts: [...sample.tierGifts], tiers: [...sample.tiers] })
mockUpdateVipPerks.mockReset().mockResolvedValue(undefined)
})

it('fetches VIP perks settings on mount and renders the panel', async () => {
const wrapper = mountView()
await flushPromises()
expect(mockGetVipPerks).toHaveBeenCalled()
expect(wrapper.text()).toContain('settings.vipPerksTitle')
})

it('add/remove tier + tier gift and save all run', async () => {
const wrapper = mountView()
await flushPromises()

const byLabel = (label: string) => wrapper.findAll('button').find((b) => b.text() === label)!

await byLabel('settings.vipPerksAddTier').trigger('click')
await byLabel('settings.vipPerksAddTierGift').trigger('click')

const trashes = wrapper.findAll('button[data-icon="pi pi-trash"]')
await trashes[0].trigger('click') // removeVipTier
await trashes[trashes.length - 1].trigger('click') // removeTierGift

await byLabel('settings.saveSettings').trigger('click')
await flushPromises()

expect(mockUpdateVipPerks).toHaveBeenCalledTimes(1)
// Blank tiers are stripped before save.
const saved = mockUpdateVipPerks.mock.calls[0][0]
expect(saved.tiers.every((t: string) => t.length > 0)).toBe(true)
// A reload follows a successful save.
expect(mockGetVipPerks).toHaveBeenCalledTimes(2)
})
})
5 changes: 5 additions & 0 deletions frontend/src/api/players.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,8 @@ export async function updatePlayerMetadata(
export async function setAdminLevel(entityId: number, level: number): Promise<void> {
await apiClient.post(`/api/players/${entityId}/admin-level`, { level })
}

export async function setPlayerTier(playerId: string, tier: string | null): Promise<PlayerMetadata> {
const response = await apiClient.put(`/api/players/metadata/${encodeURIComponent(playerId)}/tier`, { tier })
return response.data.data
}
16 changes: 16 additions & 0 deletions frontend/src/api/vipperks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import apiClient from './client'
import type { VipPerksSettings } from '@/types'

export async function getVipPerksSettings(): Promise<VipPerksSettings> {
const response = await apiClient.get('/api/vipperks/settings')
return response.data.data
}

export async function updateVipPerksSettings(settings: VipPerksSettings): Promise<void> {
await apiClient.put('/api/vipperks/settings', settings)
}

export async function getVipTiers(): Promise<string[]> {
const response = await apiClient.get('/api/vipperks/tiers')
return response.data.data ?? []
}
Loading
Loading