diff --git a/frontend/src/__tests__/api/players.test.ts b/frontend/src/__tests__/api/players.test.ts
index 48ee166..f5e9e27 100644
--- a/frontend/src/__tests__/api/players.test.ts
+++ b/frontend/src/__tests__/api/players.test.ts
@@ -22,6 +22,7 @@ import {
giveItem,
getAllPlayerMetadata,
updatePlayerMetadata,
+ setPlayerTier,
} from '@/api/players'
describe('Players API', () => {
@@ -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 })
+ })
})
diff --git a/frontend/src/__tests__/api/vipperks.test.ts b/frontend/src/__tests__/api/vipperks.test.ts
new file mode 100644
index 0000000..447bccd
--- /dev/null
+++ b/frontend/src/__tests__/api/vipperks.test.ts
@@ -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([])
+ })
+})
diff --git a/frontend/src/__tests__/components/PlayerEditDialog.test.ts b/frontend/src/__tests__/components/PlayerEditDialog.test.ts
new file mode 100644
index 0000000..93cce95
--- /dev/null
+++ b/frontend/src/__tests__/components/PlayerEditDialog.test.ts
@@ -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: '
', props: ['visible', 'header', 'modal'] },
+ Button: { template: '', props: ['label', 'icon', 'loading', 'text', 'severity', 'size'] },
+ InputText: { template: '', props: ['modelValue', 'placeholder'] },
+ Textarea: { template: '', props: ['modelValue', 'rows', 'placeholder'] },
+ ColorPicker: { template: '', props: ['modelValue'] },
+ Select: {
+ template: '',
+ 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)
+ })
+})
diff --git a/frontend/src/__tests__/components/SettingsViewVipPerks.test.ts b/frontend/src/__tests__/components/SettingsViewVipPerks.test.ts
new file mode 100644
index 0000000..ae7c030
--- /dev/null
+++ b/frontend/src/__tests__/components/SettingsViewVipPerks.test.ts
@@ -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: '
' })
+
+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: '
', props: ['value'] },
+ // Render a panel's content only for the VIP Perks tab.
+ TabPanel: { name: 'TabPanel', props: ['value'], template: '
' },
+ Card: { name: 'Card', template: '
' },
+ // No explicit @click emit: the parent's @click is a native fall-through
+ // listener on the stub's root