diff --git a/admin/src/federated-element.test.tsx b/admin/src/federated-element.test.tsx index 9538a0d..658210f 100644 --- a/admin/src/federated-element.test.tsx +++ b/admin/src/federated-element.test.tsx @@ -505,6 +505,49 @@ describe('PlaytestCreatePage', () => { expect(within(dialog).getByText('b2')).toBeInTheDocument() }) + it('greys out and disables non-buildinfo (smartbuild) builds while keeping buildinfo selectable', async () => { + setLinkages() + mockGetAdtGames.mockReturnValue({ + data: { games: [{ id: 'game-1', name: 'Starfield Dev' }] }, + isLoading: false, + error: null + }) + mockGetAdtBuilds.mockReturnValue({ + data: { + builds: [ + { id: 'b-info', name: 'v1.3', version: 'v1.3', platform: 'windows', uploadedAt: '2026-05-10T00:00:00Z', buildType: 'buildinfo' }, + { id: 'b-smart', name: 'v1.3', version: 'v1.3', platform: 'windows', uploadedAt: '2026-05-09T00:00:00Z', buildType: 'smartbuild' } + ] + }, + isLoading: false, + error: null + }) + renderAt('/new') + const user = userEvent.setup() + await user.click(screen.getByRole('radio', { name: /^ADT$/i })) + await user.click(screen.getByLabelText(/adt linkage/i)) + await user.click(await screen.findByText(/adt-ns-1/i)) + await user.click(await screen.findByRole('button', { name: /select game build/i })) + + const dialog = await screen.findByRole('dialog', { name: /select game build/i }) + await user.click(within(dialog).getByTestId('adt-picker-version-v1.3')) + + // Both build-type labels render so operators can tell them apart. + expect(within(dialog).getByTestId('adt-picker-build-type-b-info')).toHaveTextContent('buildinfo') + expect(within(dialog).getByTestId('adt-picker-build-type-b-smart')).toHaveTextContent('smartbuild') + // The smartbuild card is greyed out + carries the not-downloadable note. + expect(within(dialog).getByTestId('adt-picker-build-b-smart')).toHaveStyle({ cursor: 'not-allowed' }) + expect(within(dialog).getByText(/only buildinfo builds can be distributed/i)).toBeInTheDocument() + + const useBtn = within(dialog).getByRole('button', { name: /use this build/i }) + // Clicking the smartbuild card must NOT enable selection. + await user.click(within(dialog).getByTestId('adt-picker-build-b-smart')) + expect(useBtn).toBeDisabled() + // Clicking the buildinfo card does. + await user.click(within(dialog).getByTestId('adt-picker-build-b-info')) + expect(useBtn).toBeEnabled() + }) + it('wires both adtGameId and adtBuildId on the parent form when Use This Build is clicked', async () => { setLinkages() const mutate = vi.fn() diff --git a/admin/src/playtesthubapi/generated-definitions/V1AdtBuild.ts b/admin/src/playtesthubapi/generated-definitions/V1AdtBuild.ts index 94b09a2..e571ec3 100644 --- a/admin/src/playtesthubapi/generated-definitions/V1AdtBuild.ts +++ b/admin/src/playtesthubapi/generated-definitions/V1AdtBuild.ts @@ -10,7 +10,8 @@ export const V1AdtBuild = z.object({ name: z.string().nullish(), version: z.string().nullish(), uploadedAt: z.string().nullish(), - platform: z.string().nullish() + platform: z.string().nullish(), + buildType: z.string().nullish() }) export interface V1AdtBuild extends z.TypeOf {} diff --git a/admin/src/shared/adt-build-picker.tsx b/admin/src/shared/adt-build-picker.tsx index 62edb60..0bbdb4a 100644 --- a/admin/src/shared/adt-build-picker.tsx +++ b/admin/src/shared/adt-build-picker.tsx @@ -1,5 +1,5 @@ import { useAppUIContext } from '@accelbyte/sdk-extend-app-ui' -import { Card, Modal, Select, Space, Typography } from 'antd' +import { Card, Modal, Select, Space, Tag, Typography } from 'antd' import { useMemo, useState } from 'react' import type { V1AdtBuild } from '../playtesthubapi/generated-definitions/V1AdtBuild' import type { V1AdtGame } from '../playtesthubapi/generated-definitions/V1AdtGame' @@ -144,17 +144,41 @@ export function ADTBuildPickerModal({ {versionName && ( {versionBuilds.map(b => { + // "smartbuild" (and any other explicitly non-buildinfo + // type) cannot mint a download URL, so it is surfaced but + // greyed out + non-selectable. An empty/absent buildType + // (legacy ADT drops predating the field) stays selectable + // so the picker is never wholesale-disabled — only a build + // ADT positively labels non-buildinfo is blocked. + const buildType = (b.buildType ?? '').toLowerCase() + const downloadable = buildType === '' || buildType === 'buildinfo' const selected = b.id === pickedBuildId return ( setPickedBuildId(b.id ?? null)} - style={{ borderColor: selected ? '#1677ff' : undefined }} + hoverable={downloadable} + onClick={downloadable ? () => setPickedBuildId(b.id ?? null) : undefined} + style={{ + borderColor: selected ? '#1677ff' : undefined, + opacity: downloadable ? 1 : 0.6, + cursor: downloadable ? 'pointer' : 'not-allowed' + }} data-testid={`adt-picker-build-${b.id}`}> - {b.platform ?? 'unknown platform'} + + {b.platform ?? 'unknown platform'} + {b.buildType && ( + + {b.buildType} + + )} + + {!downloadable && ( + + Not downloadable — only buildinfo builds can be distributed. + + )} Uploaded {b.uploadedAt ?? '—'} diff --git a/admin/swaggers/playtesthub.json b/admin/swaggers/playtesthub.json index 7312f7c..4d5c097 100644 --- a/admin/swaggers/playtesthub.json +++ b/admin/swaggers/playtesthub.json @@ -2389,6 +2389,10 @@ "platform": { "type": "string", "description": "Platform — the ADT-reported target (\"windows\", \"linux\", ...). Empty\nwhen ADT does not surface it. Wire-added 2026-05-20 alongside the\nADT-eng API spec resolution." + }, + "buildType": { + "type": "string", + "description": "BuildType — the ADT `build_type` discriminator (wire-added 2026-05-28\nper the ADT-eng builds-list addendum). Observed values: \"buildinfo\"\n(a real, downloadable build) and \"smartbuild\" (a SmartBuild entry\nthat cannot mint a download URL). Only \"buildinfo\" builds are\nselectable in the admin picker; everything else is greyed out as\nnon-downloadable. Empty when ADT omits it on older drops." } }, "description": "ADTBuild is the minimum build row the admin UI / pth CLI needs to\ndrive the build picker. Mirrors pkg/adt.Build but rendered as proto." diff --git a/docs/PRD.md b/docs/PRD.md index cdad653..f0190dd 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -142,7 +142,7 @@ Applies only to playtests with `distributionModel = AGS_CAMPAIGN`. | CompleteADTLink | admin | `state`, `adt_namespace` | `ADTLinkage` | natural key (`state` — single-use; replay returns `InvalidArgument`) | M5.B | | UnlinkADT | admin | `adtLinkageId` | success/empty | idempotent (re-unlink is no-op); best-effort propagates the unlink to ADT (`adt.Client.DeleteLinkage`) in the same flow — failures are logged + counted on `ADTUnlinkADTSideFailures` but do not block the local soft-delete (PRD §4.8). | M5.B | | RecoverADTLinkage | admin | `adtNamespace` | `ADTLinkage` | not idempotent (single-use orphan adoption); rejected with `AlreadyExists` when a live row for the pair already exists | M5.B | -| ListADTBuilds | admin | `adtLinkageId`, `adtGameId` | `[]Build` (proxied through `adt.Client`) | read-only | M5.B | +| ListADTBuilds | admin | `adtLinkageId`, `adtGameId` | `[]Build` (proxied through `adt.Client`; each `Build` carries `buildType` — `buildinfo` is downloadable, `smartbuild` is not, see §4.8.3) | read-only | M5.B | | ListADTGames | admin | `adtLinkageId` | `[]Game` (proxied through `adt.Client.ListGames`; drives the create-playtest build-picker top-level dropdown — see [`STATUS_M5.md`](STATUS_M5.md) "Addendum 2026-05-21 — games-list endpoint") | read-only | M5.B | | ChangeADTBuild | admin | `playtestId`, `adtGameId`, `adtBuildId` | Playtest | repoints an ADT playtest at a new `(adtGameId, adtBuildId)` pair under its existing `adtNamespace` (immutable here — see §4.8.6); verifies the pair against the linkage via the same `adt.Client.ListBuilds` round-trip `CreatePlaytest` runs before persisting; `FailedPrecondition` on a non-ADT playtest | M5.C | | GetADTDownloadInfo | player | `playtestId` | `{url, expiresAt?, source ('issued'|'fallback')}` (gated on `applicant.status='APPROVED'` exactly like `GetGrantedCode`) | read-only (each call may mint a fresh URL per ADT semantics) | M5.B | @@ -178,6 +178,8 @@ ADT (AccelByte Development Toolkit) playtests distribute an in-development build Per the 2026-05-20 ADT API spec (see [`STATUS_M5.md`](STATUS_M5.md) §"Open questions"), ADT issues **per-build** download URLs — not per-applicant. Every approved applicant for a given playtest receives the same URL; ADT bounds it with a fixed 24-hour CDN TTL. Per-applicant audit attribution lives on the playtesthub side (`applicant.approve` row carries the applicant id + URL); per-applicant revocation is not available via ADT — `RejectApplicant` is the revocation primitive (cuts off `GetADTDownloadInfo` access on the playtesthub side; the URL itself stays valid for the TTL). +**Only `buildinfo` builds are downloadable.** ADT's builds-list response (`ListADTBuilds`) tags each row with a `build_type` discriminator (added 2026-05-28): `buildinfo` is a real build that can mint a download URL, while `smartbuild` is a SmartBuild entry that cannot. The admin build picker surfaces every build with its `build_type` label but **greys out and disables selection of non-`buildinfo` builds**, so an operator cannot point a playtest at a build that would fail at approve time. The list is shown unfiltered (both types) for transparency. + `ApproveApplicant` against an ADT playtest: - Skips code reservation entirely (no `Code` row). - Calls `adt.Client.IssueDownloadURL(adtNamespace, adtGameId, adtBuildId)` against `GET /profiling/namespaces//agsplaytesthub/games//builds//downloadUrls?limit=20`. ADT returns `{urls: [...], expiresAt}`; playtesthub surfaces the **full URL list** in ADT's original order so multi-asset builds (game binary + patcher + manifest, etc.) round-trip without data loss. Single-file builds map to a single-element list. diff --git a/docs/STATUS_M5.md b/docs/STATUS_M5.md index a757ef4..fa12e96 100644 --- a/docs/STATUS_M5.md +++ b/docs/STATUS_M5.md @@ -236,6 +236,8 @@ C13. `[x]` **ChangeADTBuild — repoint an ADT playtest's build post-create + re C14. `[x]` **ADT build-health surfacing ("build gone" on the detail page) + ChangeADTBuild persistence bug fix** — live-investigation follow-up: an `ApproveApplicant` 400 (`errorCode=1003303402` / `ErrBuildNotFound`) was only discoverable at approve time. Confirmed against live ADT (`develop.blackbox.accelbyte.io`): the `downloadUrls` issue 404s "build not found" even though the build still appears in `ListBuilds` — so a list-membership check is **insufficient**; the faithful signal is the side-effecting `IssueDownloadURL` call. Surfacing chosen = **persist-at-approve + on-demand check** (not auto-probe-on-load). Migration 0009 adds nullable `playtest.adt_build_status` (`OK`/`UNAVAILABLE`, CHECK-enforced) + `adt_build_checked_at`. New admin RPC `CheckADTBuild(playtestId)` ([`pkg/service/adt.go`](../pkg/service/adt.go)) probes `IssueDownloadURL` (ignoring the fallback URL — a fallback masks a dead build for approval but doesn't make the build healthy), maps `ErrBuildNotFound`→`UNAVAILABLE` / success→`OK`, persists via the new repo writer `SetADTBuildHealth` (which deliberately does NOT bump `updated_at`); transient ADT errors surface as gRPC errors without clobbering the last-known status. `resolveADTDownloadURL` (approve + RetryDM) records the same health opportunistically before any fallback masks it. Admin Distribution-tab Game Build card gains a health `Alert` (error when `UNAVAILABLE` w/ last-checked + "set a fallback URL or change the build" guidance; success when `OK`) + a "Check build" button. **Bug fix bundled**: `ChangeADTBuild` routed through `PgPlaytestStore.Update`, whose SET whitelist omits `adt_game_id`/`adt_build_id` — so the build change was silently dropped at the DB (the recovery path no-oped); its happy-path test passed only because the fake store cloned the whole struct. Added dedicated writer `UpdateADTBuild` + a real-PgStore integration regression test. `pth adt build check --playtest-id` CLI + smoke dry-run probe. PRD §4.8.3 + [`errors.md`](errors.md) (4 `CheckADTBuild` rows) amended. Tests: repo integration (migration cols, `UpdateADTBuild` persist + soft-deleted refusal, `SetADTBuildHealth` round-trip + updated_at untouched), 6 service cases (Check OK/UNAVAILABLE/non-ADT/not-found/transient-no-persist + approve-time persist OK/UNAVAILABLE), 4 admin vitest cases (no alert pre-check, UNAVAILABLE alert, OK alert, Check button mutation), `TestServiceDescriptorMethods` extended. +C15. `[x]` **ADT `build_type` surfacing — grey out non-downloadable (smartbuild) builds in the picker** (2026-05-28) — ADT-eng added a `build_type` discriminator to the builds-list response (`GET …/builds`, confirmed live against `develop.blackbox.accelbyte.io` v1.35.0 swagger `models.BuildSummary` + a real probe of namespace `avengers`: values observed are `buildinfo` (a real, downloadable build) and `smartbuild` (a SmartBuild entry that cannot mint a download URL). Only `buildinfo` builds can be distributed. New `Build.BuildType` field + `Build.Downloadable()` helper (case-insensitive `== "buildinfo"`) in [`pkg/adt/client.go`](../pkg/adt/client.go); [`pkg/adt/http.go`](../pkg/adt/http.go) parses `build_type`; proto `ADTBuild.build_type` (field 6) carries it through `ListADTBuilds`; [`pkg/service/adt.go`](../pkg/service/adt.go) `adtBuildToProto` maps it. The admin [`ADTBuildPickerModal`](../admin/src/shared/adt-build-picker.tsx) renders a per-build `build_type` tag (green for buildinfo, default otherwise) and **greys out + disables selection** of non-buildinfo cards (opacity, `cursor: not-allowed`, no `onClick`, "Not downloadable — only buildinfo builds can be distributed." note). The list stays **unfiltered** — both types render so operators see the full set; ADT's server-side `?buildType=` filter is deliberately not sent (documented in [`cli.md`](cli.md)). Tests: extended `pkg/adt/http_test.go` happy-path (buildinfo + smartbuild rows assert `BuildType` + `Downloadable()`), new `TestListADTBuilds_MapsBuildType` service case, new federated-element picker case (both tags render, smartbuild card greyed/disabled, "Use This Build" stays disabled on smartbuild click + enables on buildinfo). Additive response field — no migration, no new RPC, existing smoke harness stays green. **Not done (out of scope, possible follow-up)**: server-side `CreatePlaytest`/`ChangeADTBuild` rejection of a non-buildinfo `adt_build_id` (today the UI prevents selection but the API would still accept one). + **Track C milestone definition of done**: every Track A + Track B phase still green; admin shell uses the detail-page-with-tabs layout end-to-end; bulk announcement DM verified end-to-end in the golden-m5 e2e (recipients reach `SENT`); platforms column replacement deployed with the migration 0007 cutover runbook step documented and tested in a fresh testcontainers boot; PRD §4 / §5.1 / §5.4 / §5.7 / §5.9 + schema.md + errors.md + CHANGELOG v2.6 complete; the prototype's three admin scenarios match the live build screen-for-screen (UX sign-off check) **modulo the telemetry surface which is explicitly deferred to M6**. ### Track D — Survey discovery (post-M5.C polish) diff --git a/docs/cli.md b/docs/cli.md index bb1c0bd..5264171 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -146,7 +146,7 @@ PRD §4.8 / [`docs/runbooks/adt-linking.md`](runbooks/adt-linking.md). All seven | `pth adt linkage complete --state --adt-namespace ` | `CompleteADTLink` | Idempotent on duplicate `(studio, adt_namespace)`; replay with a consumed `state` returns `InvalidArgument`. | | `pth adt linkage unlink --id ` | `UnlinkADT` | Idempotent; best-effort `DELETE` against ADT to drop its side's flag. | | `pth adt linkage recover --adt-namespace ` | `RecoverADTLinkage` | Adopts an orphan ADT-side flag (flag present on ADT, no local row) without an OAuth round-trip. Probes ADT first; `FailedPrecondition` if no flag exists, `AlreadyExists` if a non-deleted local row already covers `(studio, adt_namespace)`. | -| `pth adt build list --linkage-id --game-id ` | `ListADTBuilds` | Defense-in-depth check: `CreatePlaytest` ADT branch reuses the same path to verify the picked `adt_build_id` belongs to `(adt_namespace, adt_game_id)`. | +| `pth adt build list --linkage-id --game-id ` | `ListADTBuilds` | Defense-in-depth check: `CreatePlaytest` ADT branch reuses the same path to verify the picked `adt_build_id` belongs to `(adt_namespace, adt_game_id)`. Each row carries `buildType` (ADT `build_type`): `buildinfo` builds are downloadable; `smartbuild` (and any other type) cannot mint a download URL and are greyed out / non-selectable in the admin picker. The list is unfiltered (both types returned); ADT also accepts a server-side `?buildType=buildinfo\|smartbuild` filter, which playtesthub deliberately does not send so operators see the full set. | | `pth adt build check --playtest-id ` | `CheckADTBuild` | Probes whether the playtest's current build can still mint a download URL (same call as `ApproveApplicant`) and persists `adt_build_status`. `healthy=false` + `UNAVAILABLE` when ADT returns build-not-found; surfaces the dead build on the detail page without an approval attempt (M5.C). | | `pth adt games list --linkage-id ` | `ListADTGames` | Drives the admin build-picker top-level dropdown so operators no longer type the `adt_game_id` by hand. STATUS_M5.md B12. | | `pth adt diagnostics` | `GetADTClientDiagnostics` | Reports which `adt.Client` kind the bootapp wired (`http` vs `mem`) plus the presence (booleans only — never values) of every env var that feeds the gate (`PLUGIN_GRPC_SERVER_AUTH_ENABLED`, `ADT_BASE_URL`, `AGS_BASE_URL`, `AGS_IAM_CLIENT_ID`, `AGS_IAM_CLIENT_SECRET`). Use when `UnlinkADT` appears to soft-delete locally but ADT still reports the linkage — `mem` here means the boot gate silently fell back and ADT-side propagation is a no-op. | diff --git a/gateway/apidocs/api.swagger.json b/gateway/apidocs/api.swagger.json index 7312f7c..4d5c097 100644 --- a/gateway/apidocs/api.swagger.json +++ b/gateway/apidocs/api.swagger.json @@ -2389,6 +2389,10 @@ "platform": { "type": "string", "description": "Platform — the ADT-reported target (\"windows\", \"linux\", ...). Empty\nwhen ADT does not surface it. Wire-added 2026-05-20 alongside the\nADT-eng API spec resolution." + }, + "buildType": { + "type": "string", + "description": "BuildType — the ADT `build_type` discriminator (wire-added 2026-05-28\nper the ADT-eng builds-list addendum). Observed values: \"buildinfo\"\n(a real, downloadable build) and \"smartbuild\" (a SmartBuild entry\nthat cannot mint a download URL). Only \"buildinfo\" builds are\nselectable in the admin picker; everything else is greyed out as\nnon-downloadable. Empty when ADT omits it on older drops." } }, "description": "ADTBuild is the minimum build row the admin UI / pth CLI needs to\ndrive the build picker. Mirrors pkg/adt.Build but rendered as proto." diff --git a/pkg/adt/client.go b/pkg/adt/client.go index 2842156..bf72be8 100644 --- a/pkg/adt/client.go +++ b/pkg/adt/client.go @@ -39,6 +39,7 @@ package adt import ( "context" + "strings" "time" ) @@ -130,6 +131,7 @@ type Client interface { // short-hash-style version tag) // UploadedAt ← ADT `created_at` (RFC3339) // Platform ← ADT `platform_name` (e.g. "windows") +// BuildType ← ADT `build_type` ("buildinfo" | "smartbuild") type Build struct { ID string Name string @@ -138,6 +140,32 @@ type Build struct { // Platform is the ADT-reported target platform ("windows" / // "linux" / etc.). Empty when ADT does not surface it. Platform string + // BuildType is ADT's `build_type` discriminator (added to the + // builds-list response 2026-05-28). Observed values: "buildinfo" — + // a real build that can mint a download URL — and "smartbuild" — a + // SmartBuild entry that cannot be downloaded. Only BuildTypeBuildInfo + // is downloadable; the admin picker greys the rest out. Empty when + // ADT omits the field on older drops. + BuildType string +} + +// Build-type discriminators ADT returns on the builds-list response +// (`build_type`). Only buildinfo builds can mint a download URL; the +// admin picker greys everything else out as non-downloadable. +const ( + BuildTypeBuildInfo = "buildinfo" + BuildTypeSmartBuild = "smartbuild" +) + +// Downloadable reports whether this build may be distributed to +// playtesters. "smartbuild" (and any future explicitly non-buildinfo +// type) cannot mint a download URL and is reported false. An empty +// BuildType is treated as downloadable — legacy ADT drops that predate +// the `build_type` field must not have every build greyed out — so only +// a build ADT positively labels non-buildinfo is excluded. The check is +// case-insensitive to tolerate ADT casing drift. +func (b Build) Downloadable() bool { + return b.BuildType == "" || strings.EqualFold(b.BuildType, BuildTypeBuildInfo) } // Game is the minimum game row the admin UI / pth CLI needs to drive diff --git a/pkg/adt/http.go b/pkg/adt/http.go index 4777719..a03c013 100644 --- a/pkg/adt/http.go +++ b/pkg/adt/http.go @@ -144,6 +144,7 @@ func (c *HTTPClient) ListBuilds(ctx context.Context, studioNamespace, adtNamespa GameVersionID string `json:"game_version_id"` CreatedAt string `json:"created_at"` PlatformName string `json:"platform_name"` + BuildType string `json:"build_type"` } `json:"data"` } if err := c.Policy.Run(ctx, "ListBuilds", func(attemptCtx context.Context) error { @@ -165,6 +166,7 @@ func (c *HTTPClient) ListBuilds(ctx context.Context, studioNamespace, adtNamespa Version: b.GameVersionID, UploadedAt: uploaded, Platform: b.PlatformName, + BuildType: b.BuildType, }) } return out, nil diff --git a/pkg/adt/http_test.go b/pkg/adt/http_test.go index fd55f48..1e33652 100644 --- a/pkg/adt/http_test.go +++ b/pkg/adt/http_test.go @@ -102,6 +102,15 @@ func TestHTTPClient_ListBuilds_HappyPath(t *testing.T) { "build_name": "ignored-build-name", "created_at": "2026-05-20T12:00:00Z", "platform_name": "windows", + "build_type": "buildinfo", + }, + { + "id": "build-2", + "game_version_name": "v1.2.3 SB", + "game_version_id": "def", + "created_at": "2026-05-21T12:00:00Z", + "platform_name": "windows", + "build_type": "smartbuild", }, }, }) @@ -113,8 +122,8 @@ func TestHTTPClient_ListBuilds_HappyPath(t *testing.T) { if err != nil { t.Fatalf("err = %v", err) } - if len(builds) != 1 { - t.Fatalf("len(builds) = %d, want 1", len(builds)) + if len(builds) != 2 { + t.Fatalf("len(builds) = %d, want 2", len(builds)) } if builds[0].ID != "build-1" || builds[0].Name != "v1.2.3" || builds[0].Version != "abc" || builds[0].Platform != "windows" { t.Errorf("build = %+v", builds[0]) @@ -122,6 +131,12 @@ func TestHTTPClient_ListBuilds_HappyPath(t *testing.T) { if builds[0].UploadedAt.IsZero() { t.Errorf("UploadedAt zero, want parsed") } + if builds[0].BuildType != "buildinfo" || !builds[0].Downloadable() { + t.Errorf("build[0] type = %q downloadable = %v, want buildinfo/true", builds[0].BuildType, builds[0].Downloadable()) + } + if builds[1].BuildType != "smartbuild" || builds[1].Downloadable() { + t.Errorf("build[1] type = %q downloadable = %v, want smartbuild/false", builds[1].BuildType, builds[1].Downloadable()) + } if capturedAuth != bearerSvcJWT { t.Errorf("Authorization = %q", capturedAuth) } diff --git a/pkg/pb/playtesthub/v1/playtesthub.pb.go b/pkg/pb/playtesthub/v1/playtesthub.pb.go index c2796f1..5ca2e84 100644 --- a/pkg/pb/playtesthub/v1/playtesthub.pb.go +++ b/pkg/pb/playtesthub/v1/playtesthub.pb.go @@ -5830,7 +5830,14 @@ type ADTBuild struct { // Platform — the ADT-reported target ("windows", "linux", ...). Empty // when ADT does not surface it. Wire-added 2026-05-20 alongside the // ADT-eng API spec resolution. - Platform string `protobuf:"bytes,5,opt,name=platform,proto3" json:"platform,omitempty"` + Platform string `protobuf:"bytes,5,opt,name=platform,proto3" json:"platform,omitempty"` + // BuildType — the ADT `build_type` discriminator (wire-added 2026-05-28 + // per the ADT-eng builds-list addendum). Observed values: "buildinfo" + // (a real, downloadable build) and "smartbuild" (a SmartBuild entry + // that cannot mint a download URL). Only "buildinfo" builds are + // selectable in the admin picker; everything else is greyed out as + // non-downloadable. Empty when ADT omits it on older drops. + BuildType string `protobuf:"bytes,6,opt,name=build_type,json=buildType,proto3" json:"build_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -5900,6 +5907,13 @@ func (x *ADTBuild) GetPlatform() string { return "" } +func (x *ADTBuild) GetBuildType() string { + if x != nil { + return x.BuildType + } + return "" +} + type ListADTLinkagesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Mirrors the existing admin RPC namespace path-param convention. @@ -8060,14 +8074,16 @@ const file_playtesthub_v1_playtesthub_proto_rawDesc = "" + "\x11linked_by_user_id\x18\x04 \x01(\tR\x0elinkedByUserId\x127\n" + "\tlinked_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\blinkedAt\x129\n" + "\n" + - "deleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tdeletedAt\"\xa1\x01\n" + + "deleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tdeletedAt\"\xc0\x01\n" + "\bADTBuild\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + "\aversion\x18\x03 \x01(\tR\aversion\x12;\n" + "\vuploaded_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + "uploadedAt\x12\x1a\n" + - "\bplatform\x18\x05 \x01(\tR\bplatform\"6\n" + + "\bplatform\x18\x05 \x01(\tR\bplatform\x12\x1d\n" + + "\n" + + "build_type\x18\x06 \x01(\tR\tbuildType\"6\n" + "\x16ListADTLinkagesRequest\x12\x1c\n" + "\tnamespace\x18\x01 \x01(\tR\tnamespace\"Q\n" + "\x17ListADTLinkagesResponse\x126\n" + diff --git a/pkg/service/adt.go b/pkg/service/adt.go index 268fb8c..06d409b 100644 --- a/pkg/service/adt.go +++ b/pkg/service/adt.go @@ -777,10 +777,11 @@ func adtLinkageToProto(r *repo.ADTLinkage) *pb.ADTLinkage { func adtBuildToProto(b adt.Build) *pb.ADTBuild { out := &pb.ADTBuild{ - Id: b.ID, - Name: b.Name, - Version: b.Version, - Platform: b.Platform, + Id: b.ID, + Name: b.Name, + Version: b.Version, + Platform: b.Platform, + BuildType: b.BuildType, } if !b.UploadedAt.IsZero() { out.UploadedAt = timestamppb.New(b.UploadedAt) diff --git a/pkg/service/adt_list_builds_test.go b/pkg/service/adt_list_builds_test.go new file mode 100644 index 0000000..a9cf8e7 --- /dev/null +++ b/pkg/service/adt_list_builds_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "testing" + "time" + + "github.com/google/uuid" + + "github.com/anggorodewanto/playtesthub/pkg/adt" + pb "github.com/anggorodewanto/playtesthub/pkg/pb/playtesthub/v1" +) + +// TestListADTBuilds_MapsBuildType verifies the service handler surfaces +// ADT's `build_type` discriminator end-to-end so the admin picker can +// grey out non-downloadable (smartbuild) entries. Wire-added 2026-05-28 +// alongside the ADT builds-list addendum. +func TestListADTBuilds_MapsBuildType(t *testing.T) { + h := newADTTestServer(t) + linkage := h.linkage.live[testStudioNamespace+"|"+testADTNamespace] + h.mem.SeedBuilds(testADTNamespace, testADTGameID, []adt.Build{ + {ID: "b-info", Name: "0.0.3", Version: "v3", Platform: "windows", BuildType: adt.BuildTypeBuildInfo, UploadedAt: time.Now()}, + {ID: "b-smart", Name: "0.0.2 SB", Version: "v2", Platform: "windows", BuildType: adt.BuildTypeSmartBuild, UploadedAt: time.Now().Add(-time.Hour)}, + }) + + resp, err := h.svr.ListADTBuilds(authCtx(uuid.New()), &pb.ListADTBuildsRequest{ + Namespace: testNamespace, + AdtLinkageId: linkage.ID.String(), + AdtGameId: testADTGameID, + }) + if err != nil { + t.Fatalf("ListADTBuilds: %v", err) + } + byID := map[string]*pb.ADTBuild{} + for _, b := range resp.GetBuilds() { + byID[b.GetId()] = b + } + if got := byID["b-info"].GetBuildType(); got != "buildinfo" { + t.Errorf("buildinfo build_type = %q, want buildinfo", got) + } + if got := byID["b-smart"].GetBuildType(); got != "smartbuild" { + t.Errorf("smartbuild build_type = %q, want smartbuild", got) + } +} diff --git a/proto/playtesthub/v1/playtesthub.proto b/proto/playtesthub/v1/playtesthub.proto index 71cfe71..00b67de 100644 --- a/proto/playtesthub/v1/playtesthub.proto +++ b/proto/playtesthub/v1/playtesthub.proto @@ -1577,6 +1577,13 @@ message ADTBuild { // when ADT does not surface it. Wire-added 2026-05-20 alongside the // ADT-eng API spec resolution. string platform = 5; + // BuildType — the ADT `build_type` discriminator (wire-added 2026-05-28 + // per the ADT-eng builds-list addendum). Observed values: "buildinfo" + // (a real, downloadable build) and "smartbuild" (a SmartBuild entry + // that cannot mint a download URL). Only "buildinfo" builds are + // selectable in the admin picker; everything else is greyed out as + // non-downloadable. Empty when ADT omits it on older drops. + string build_type = 6; } message ListADTLinkagesRequest {