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
22 changes: 19 additions & 3 deletions admin/src/PlaytestDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,23 +476,39 @@ describe('PlaytestDetailPage shell', () => {
it('Copy share link uses the backend-supplied player_base_url, not the admin host', async () => {
mockGetPublicConfig.mockReturnValue({ data: { playerBaseUrl: 'https://play.example.com/' } })
const user = userEvent.setup()
renderDetail('autumn-draft')
// The header "Playtest Link" button only renders for OPEN/CLOSED (hidden in DRAFT).
renderDetail('autumn-open')
await user.click(screen.getByRole('button', { name: /Playtest Link/ }))
await waitFor(async () => {
const text = await navigator.clipboard.readText()
expect(text).toBe('https://play.example.com/#/playtest/autumn-draft')
expect(text).toBe('https://play.example.com/#/playtest/autumn-open')
})
})

it('Copy share link shows an error when player_base_url is unset (no clipboard write)', async () => {
mockGetPublicConfig.mockReturnValue({ data: { playerBaseUrl: '' } })
const user = userEvent.setup()
renderDetail('autumn-draft')
renderDetail('autumn-open')
await user.click(screen.getByRole('button', { name: /Playtest Link/ }))
expect(await screen.findByText(/PLAYER_BASE_URL/)).toBeInTheDocument()
expect(await navigator.clipboard.readText()).toBe('')
})

it('hides the player-facing link (header button + share input) while DRAFT', () => {
renderDetail('autumn-draft')
expect(screen.queryByText('Playtest Link')).not.toBeInTheDocument()
expect(screen.queryByTestId('playtest-share-link')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue(/play\.example\.com/)).not.toBeInTheDocument()
expect(screen.getByText(/becomes available once the playtest is published/i)).toBeInTheDocument()
})

it('shows the player-facing link (header button + share input) once OPEN', () => {
renderDetail('autumn-open')
expect(screen.getByRole('button', { name: /Playtest Link/ })).toBeInTheDocument()
const share = screen.getByTestId('playtest-share-link')
expect(within(share).getByDisplayValue('https://play.example.com/#/playtest/autumn-open')).toBeInTheDocument()
})

it('Publish click triggers the transition mutation via confirm modal', async () => {
const mutate = vi.fn()
mockTransition.mockReturnValue({ mutate })
Expand Down
57 changes: 33 additions & 24 deletions admin/src/PlaytestDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,18 @@ export function PlaytestDetailPage() {
</Typography.Title>
<Space size={12} style={{ marginTop: 4 }}>
<Typography.Text type="secondary">{formatDateRange(playtest.startsAt, playtest.endsAt)}</Typography.Text>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
iconPosition="end"
onClick={copyShareLink}
style={{ padding: 0, height: 'auto' }}
>
Playtest Link
</Button>
{!isDraft && (
<Button
type="link"
size="small"
icon={<CopyOutlined />}
iconPosition="end"
onClick={copyShareLink}
style={{ padding: 0, height: 'auto' }}
>
Playtest Link
</Button>
)}
</Space>
</div>
<Space wrap data-testid="playtest-header-actions">
Expand Down Expand Up @@ -235,6 +237,7 @@ const DISTRIBUTION_MODEL_LABEL: Record<string, string> = {

function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; playerBaseUrl: string }) {
const navigate = useNavigate()
const isDraft = playtest.status === PlaytestStatus.DRAFT

const distributionLabel = playtest.distributionModel
? (DISTRIBUTION_MODEL_LABEL[playtest.distributionModel] ?? playtest.distributionModel)
Expand Down Expand Up @@ -301,21 +304,27 @@ function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; pl
))}
</Card>

<div data-testid="playtest-share-link">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Shareable Sign-Up Link
{isDraft ? (
<Typography.Text type="secondary">
Sign-up link becomes available once the playtest is published.
</Typography.Text>
<Input
readOnly
value={shareLink}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace' }}
suffix={
<Button type="link" size="small" onClick={copyShareLink} style={{ padding: 0 }}>
Copy
</Button>
}
/>
</div>
) : (
<div data-testid="playtest-share-link">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Shareable Sign-Up Link
</Typography.Text>
<Input
readOnly
value={shareLink}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace' }}
suffix={
<Button type="link" size="small" onClick={copyShareLink} style={{ padding: 0 }}>
Copy
</Button>
}
/>
</div>
)}
</Space>
)
}
Expand Down
97 changes: 97 additions & 0 deletions admin/src/federated-element.scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'

// PM fix #1: the Create button sits far below the required fields, so antd's
// default validation jump-to-top leaves the error off-screen. We pass
// scrollToFirstError to both create + edit Forms. Scroll behavior can't be
// observed in jsdom, so we partially mock antd to capture the props the
// <Form> is constructed with and assert the prop is wired. This mock is
// isolated to this file so the main suite still renders the real antd Form.
const formProps: Array<Record<string, unknown>> = []

vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd')
const RealForm = actual.Form as unknown as React.ComponentType<Record<string, unknown>> & Record<string, unknown>
const FormSpy = (props: Record<string, unknown>) => {
formProps.push(props)
return <RealForm {...props} />
}
// Preserve the statics antd hangs off Form (useForm, useWatch, Item, ...).
Object.assign(FormSpy, RealForm)
return { ...actual, Form: FormSpy }
})

vi.mock('@accelbyte/sdk-extend-app-ui', () => ({
useAppUIContext: () => ({ sdk: {}, isCurrentUserHasPermission: () => true }),
CrudType: { READ: 'READ', CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' }
}))

const noopQuery = { data: undefined, isLoading: false, error: null, refetch: vi.fn() }
const noopMutation = { mutate: vi.fn(), isPending: false, isError: false, error: null }

vi.mock('./playtesthubapi/generated-public/queries/PlaytesthubService.query', () => ({
usePlaytesthubServiceApi_GetConfig: () => ({ data: { playerBaseUrl: 'https://play.example.com' } })
}))

vi.mock('./playtesthubapi/generated-admin/queries/PlaytesthubServiceAdmin.query', () => ({
Key_PlaytesthubServiceAdmin: { Playtests: 'playtests', Playtest_ByPlaytestId: 'playtest-by-id', AdtLinkages: 'adt-linkages' },
usePlaytesthubServiceAdminApi_GetPlaytests: () => ({ data: { playtests: [] }, isLoading: false, error: null, refetch: vi.fn() }),
usePlaytesthubServiceAdminApi_GetPlaytest_ByPlaytestId: () => ({
data: {
playtest: {
id: 'pt_1',
slug: 'summer-alpha',
title: 'Summer Alpha',
platforms: ['PLATFORM_STEAM'],
distributionModel: 'DISTRIBUTION_MODEL_STEAM_KEYS',
ndaRequired: false
}
},
isLoading: false,
error: null
}),
usePlaytesthubServiceAdminApi_CreatePlaytestMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_DeletePlaytest_ByPlaytestIdMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_PatchPlaytest_ByPlaytestIdMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_CreatePlaytest_ByPlaytestIdTransitionStatuMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_GetWorkersHealth: () => ({ data: { workers: [] }, isLoading: false, error: null }),
usePlaytesthubServiceAdminApi_GetAdtLinkages: () => ({ data: { linkages: [] }, isLoading: false, error: null }),
usePlaytesthubServiceAdminApi_GetGamesAdt_ByAdtLinkageId: () => noopQuery,
usePlaytesthubServiceAdminApi_CreateAdtLinkagesStartMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_CreateAdtLinkagesCompleteMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_CreateAdtLinkagesRecoverMutation: () => noopMutation,
usePlaytesthubServiceAdminApi_DeleteAdtLinkage_ByAdtLinkageIdMutation: () => noopMutation
}))

import { FederatedElement } from './federated-element'

function renderAt(path: string) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[path]}>
<FederatedElement />
</MemoryRouter>
</QueryClientProvider>
)
}

beforeEach(() => {
formProps.length = 0
})

describe('scrollToFirstError (PM fix #1)', () => {
it('passes scrollToFirstError on the create Form', () => {
renderAt('/new')
const withScroll = formProps.find(p => p.scrollToFirstError)
expect(withScroll?.scrollToFirstError).toEqual({ behavior: 'smooth', block: 'center' })
})

it('passes scrollToFirstError on the edit Form', () => {
renderAt('/pt_1/edit')
const withScroll = formProps.find(p => p.scrollToFirstError)
expect(withScroll?.scrollToFirstError).toEqual({ behavior: 'smooth', block: 'center' })
})
})
62 changes: 62 additions & 0 deletions admin/src/federated-element.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,68 @@ describe('PlaytestCreatePage', () => {
expect(screen.getByLabelText(/playtest title/i)).toHaveValue('My Title')
})
})

// PM fix #2: a duplicate slug returns gRPC AlreadyExists (code 6) → HTTP 409.
// The inline Alert must show the friendly slug-conflict copy, not the generic
// "Create failed" fallback. The message text is implementation-defined so we
// drive the mutation into the {code:6}/409 shape and assert on the UI copy.
describe('create error surfacing (PM fix #2)', () => {
it('shows the slug-conflict copy when the create mutation fails with code 6 / 409', () => {
mockCreateMutation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isError: true,
error: { response: { status: 409, data: { code: 6, message: 'slug "x" already exists in namespace "y"', details: [] } } }
})
renderAt('/new')
expect(screen.getByText('Slug is already in-use, please use another')).toBeInTheDocument()
})

it('shows the server message for a non-slug error (e.g. code 13 / 500)', () => {
mockCreateMutation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isError: true,
error: { response: { status: 500, data: { code: 13, message: 'internal boom', details: [] } } }
})
renderAt('/new')
expect(screen.getByText('internal boom')).toBeInTheDocument()
expect(screen.queryByText('Slug is already in-use, please use another')).not.toBeInTheDocument()
})
})

// PM fix #5: when no ADT linkage exists, the ADT radio card carries the
// "linking required" warning. Add a Link ADT button beside it that drives the
// existing StartADTLink flow (the LinkADTModal → start mutation).
describe('ADT-not-linked Link button (PM fix #5)', () => {
it('renders a Link ADT Namespace button when linkageCount is 0', () => {
mockGetAdtLinkages.mockReturnValue({ data: { linkages: [] }, isLoading: false, error: null })
renderAt('/new')
expect(screen.getByRole('button', { name: /link adt namespace/i })).toBeInTheDocument()
})

it('does not render the Link ADT button once a linkage exists', () => {
mockGetAdtLinkages.mockReturnValue({
data: { linkages: [{ id: 'lnk-1', adtNamespace: 'adt-ns-1', studioNamespace: 'studio-A' }] },
isLoading: false,
error: null
})
renderAt('/new')
expect(screen.queryByRole('button', { name: /link adt namespace/i })).not.toBeInTheDocument()
})

it('opens the Link ADT modal and fires StartADTLink on Proceed', async () => {
const startMutate = vi.fn()
mockStartAdtLinkMutation.mockReturnValue({ mutate: startMutate, isPending: false, isError: false, error: null })
mockGetAdtLinkages.mockReturnValue({ data: { linkages: [] }, isLoading: false, error: null })
renderAt('/new')
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /link adt namespace/i }))
expect(await screen.findByText(/you will be redirected to ADT to authorise the linkage/i)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /^proceed$/i }))
expect(startMutate).toHaveBeenCalledWith({ data: {} })
})
})
})

describe('ADTLinkagesPanel', () => {
Expand Down
Loading
Loading