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
32 changes: 26 additions & 6 deletions src/features/clusters/queries/getHarperVersionsQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ describe('dedupeHarperVersionsByTag', () => {
});

describe('getHarperVersionsOptions', () => {
it('configures the query to hit the HarperVersions cache key without retrying', () => {
const options = getHarperVersionsOptions();
expect(options.queryKey).toEqual(['HarperVersions']);
it('scopes the cache key to the organization (org-first) and does not retry', () => {
const options = getHarperVersionsOptions('org_123');
// Org-first so it participates in org-scoped `invalidateQueries([organizationId])`.
expect(options.queryKey).toEqual(['org_123', 'HarperVersions']);
expect(options.staleTime).toBe(60_000);
expect(options.retry).toBe(false);
});
Expand All @@ -108,10 +109,10 @@ describe('getHarperVersionsOptions', () => {
} satisfies HarperVersionsResponse,
});

const options = getHarperVersionsOptions();
const options = getHarperVersionsOptions('org_1');
const result = await (options.queryFn as () => Promise<HarperVersionsResponse>)();

expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/');
expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org_1' } });
expect(result).toEqual({
name: 'Harper Versions',
description: 'Available Harper versions',
Expand All @@ -122,14 +123,33 @@ describe('getHarperVersionsOptions', () => {
});
});

it('passes the organizationId through axios params (axios handles encoding)', async () => {
mockedGet.mockResolvedValue({
data: {
name: 'Harper Versions',
description: 'Available Harper versions',
value: [
{ name: 'stable', version: '5.1.21' },
{ name: 'deployed on prod-east', version: '5.0.8' },
],
} satisfies HarperVersionsResponse,
});

const options = getHarperVersionsOptions('org/1');
const result = await (options.queryFn as () => Promise<HarperVersionsResponse>)();

expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org/1' } });
expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed on prod-east']);
});

it('surfaces (rather than swallows) a malformed response with no value array', async () => {
// The endpoint's OpenAPI description is broken, so the `as HarperVersionsResponse` cast is not
// schema-backed. If it ever returns a body without `value`, the queryFn throws — React Query's
// QueryCache.onError surfaces it and every consumer null-checks `harperVersions?.value`, so it
// fails safely rather than silently rendering an empty picker.
mockedGet.mockResolvedValue({ data: { name: 'Harper Versions', description: '' } });

const options = getHarperVersionsOptions();
const options = getHarperVersionsOptions('org_1');

await expect((options.queryFn as () => Promise<HarperVersionsResponse>)()).rejects.toThrow();
});
Expand Down
17 changes: 12 additions & 5 deletions src/features/clusters/queries/getHarperVersionsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,27 @@ export function dedupeHarperVersionsByTag(versions: HarperVersion[]): HarperVers
return [...bestByVersion.values()];
}

async function getHarperVersions() {
async function getHarperVersions(organizationId: string) {
// TODO: OpenAPI from CM is erroring, so this new endpoint isn't described.
const { data } = await apiClient.get(`/HarperVersions/` as any);
// The list is org-scoped: enterprise orgs also get the versions currently deployed on their
// clusters (labeled with the cluster) merged in server-side.
const { data } = await apiClient.get(`/HarperVersions/` as any, {
params: { organizationId },
});
const response = data as HarperVersionsResponse;
return {
...response,
value: dedupeHarperVersionsByTag(response.value),
} satisfies HarperVersionsResponse;
}

export function getHarperVersionsOptions() {
export function getHarperVersionsOptions(organizationId: string) {
return queryOptions({
queryKey: ['HarperVersions'],
queryFn: getHarperVersions,
// Org-first, matching the sibling queries (getPlanTypesQuery, getRegionLocationsQuery, …) so
// org-scoped invalidations — `queryClient.invalidateQueries({ queryKey: [organizationId] })`,
// used after cluster ops that can change deployed versions — also refresh this list.
queryKey: [organizationId, 'HarperVersions'],
queryFn: () => getHarperVersions(organizationId),
staleTime: 60_000,
retry: false,
});
Expand Down
19 changes: 10 additions & 9 deletions src/features/clusters/upsert/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ export function UpsertCluster() {
}));
const { data: regionLocationsDedicated } = useQuery(getRegionLocationsOptions({ organizationId }));

const { data: newHarperVersions } = useQuery(getHarperVersionsOptions());
const { data: newHarperVersions } = useQuery(getHarperVersionsOptions(organizationId));
const harperVersions = useMemo(() => {
if (cluster) {
const clusterVersions = cluster.instances?.map(i => i.version).filter(excludeFalsy);
if (newHarperVersions && clusterVersions) {
const latestClusterVersion = clusterVersions.sort(compareVersions).pop();
// Copy before sort — sort mutates in place, and we reuse the full set below.
const latestClusterVersion = [...clusterVersions].sort(compareVersions).pop();
const clusterVersionSet = new Set(clusterVersions);
return {
...newHarperVersions,
value: [
Expand All @@ -87,13 +89,12 @@ export function UpsertCluster() {
version: latestClusterVersion,
} as const,
...(newHarperVersions?.value || []).filter(v => {
// Is our version unique from the latest cluster version?
return latestClusterVersion !== v.version
// Do we have a cluster version?
&& (!latestClusterVersion
// Or if we do, have we updated to a higher version already?
// This can prevent upgrading to, say, "next" v5, and then downgrading to the "latest" v4.
|| wasAReleasedBeforeB(latestClusterVersion, v.version));
// Drop any version this cluster already runs: the current version is shown once as
// "current" above, and the backend also returns it (and any co-tenant instance's
// version, mid-upgrade) as a "deployed on <cluster>" entry we don't want to duplicate.
return !clusterVersionSet.has(v.version)
// Only offer newer releases — no downgrades (e.g. don't drop from "next" v5 to "stable" v4).
&& (!latestClusterVersion || wasAReleasedBeforeB(latestClusterVersion, v.version));
}),
].filter(excludeFalsy),
} satisfies HarperVersionsResponse;
Expand Down