From f176d4093a71d08c539b02d45a1d459b235fa4a9 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 15 Apr 2026 13:22:28 +0200 Subject: [PATCH 1/3] fix: redirect machine sources away from squads routes --- .../webapp/__tests__/SourceRouting.spec.ts | 103 ++++++++++++++++++ .../webapp/pages/squads/[handle]/index.tsx | 42 ++++++- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 packages/webapp/__tests__/SourceRouting.spec.ts diff --git a/packages/webapp/__tests__/SourceRouting.spec.ts b/packages/webapp/__tests__/SourceRouting.spec.ts new file mode 100644 index 0000000000..1dfab2d70a --- /dev/null +++ b/packages/webapp/__tests__/SourceRouting.spec.ts @@ -0,0 +1,103 @@ +import { gqlClient, ApiError } from '@dailydotdev/shared/src/graphql/common'; +import { getSquadStaticFields } from '@dailydotdev/shared/src/graphql/squads'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { getStaticProps as getSourceStaticProps } from '../pages/sources/[source]'; +import { getServerSideProps as getSquadServerSideProps } from '../pages/squads/[handle]'; + +jest.mock('@dailydotdev/shared/src/graphql/common', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); + + return { + ...actual, + gqlClient: { + request: jest.fn(), + }, + }; +}); + +jest.mock('@dailydotdev/shared/src/graphql/squads', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/squads'); + + return { + ...actual, + getSquadStaticFields: jest.fn(), + }; +}); + +const mockRequest = gqlClient.request as jest.MockedFunction< + typeof gqlClient.request +>; +const mockGetSquadStaticFields = + getSquadStaticFields as jest.MockedFunction; + +describe('source and squad route redirects', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockGetSquadStaticFields.mockReset(); + }); + + it('redirects squad sources from /sources/[source] to /squads/[source]', async () => { + mockRequest.mockResolvedValueOnce({ + source: { + id: 'frontend', + name: 'Frontend', + image: 'https://daily.dev/frontend.png', + handle: 'frontend', + permalink: 'https://app.daily.dev/squads/frontend', + type: SourceType.Squad, + public: true, + }, + }); + + const result = await getSourceStaticProps({ + params: { source: 'frontend' }, + } as never); + + expect(result).toEqual({ + redirect: { + destination: '/squads/frontend', + permanent: false, + }, + }); + }); + + it('redirects machine sources from /squads/[handle] to /sources/[handle]', async () => { + mockGetSquadStaticFields.mockRejectedValueOnce({ + response: { + errors: [ + { + extensions: { + code: ApiError.NotFound, + }, + }, + ], + }, + }); + mockRequest.mockResolvedValueOnce({ + source: { + id: 'daily', + name: 'daily.dev', + image: 'https://daily.dev/daily.png', + handle: 'daily', + permalink: 'https://app.daily.dev/sources/daily', + type: SourceType.Machine, + public: true, + }, + }); + + const setHeader = jest.fn(); + const result = await getSquadServerSideProps({ + params: { handle: 'daily' }, + query: {}, + res: { setHeader }, + } as never); + + expect(setHeader).toHaveBeenCalled(); + expect(result).toEqual({ + redirect: { + destination: '/sources/daily', + permanent: false, + }, + }); + }); +}); diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index 79493bb513..db3b672bb0 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -26,8 +26,14 @@ import { } from '@dailydotdev/shared/src/graphql/squads'; import type { BasicSourceMember, + SourceData, Squad, } from '@dailydotdev/shared/src/graphql/sources'; +import { + isSourceUserSource, + SOURCE_QUERY, + SourceType, +} from '@dailydotdev/shared/src/graphql/sources'; import Unauthorized from '@dailydotdev/shared/src/components/errors/Unauthorized'; import { useQuery } from '@tanstack/react-query'; import { @@ -553,8 +559,42 @@ export async function getServerSideProps({ } catch (err) { const clientError = err as ClientError; const errors = Object.values(ApiError); + const errorCode = clientError?.response?.errors?.[0]?.extensions?.code; + + if (errorCode === ApiError.NotFound) { + try { + const sourceResult = await gqlClient.request(SOURCE_QUERY, { + id: handle, + }); + + if (isSourceUserSource(sourceResult.source)) { + setCacheHeader(); + + return { + redirect: { + destination: `/${sourceResult.source.id}`, + permanent: false, + }, + }; + } + + if (sourceResult.source?.type === SourceType.Machine) { + setCacheHeader(); + + return { + redirect: { + destination: `/sources/${handle}`, + permanent: false, + }, + }; + } + } catch { + // Fall through to the existing not found behavior when the handle + // doesn't resolve to a non-squad source either. + } + } - if (errors.includes(clientError?.response?.errors?.[0]?.extensions?.code)) { + if (errors.includes(errorCode)) { setCacheHeader(); return { From 5a1158abaca71c45e4cd92f744109b160b4afc0c Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 15 Apr 2026 13:44:51 +0200 Subject: [PATCH 2/3] fix: align squad redirects with source routing --- .../webapp/__tests__/SourceRouting.spec.ts | 14 +---- .../webapp/pages/squads/[handle]/index.tsx | 59 ++++++++----------- 2 files changed, 28 insertions(+), 45 deletions(-) diff --git a/packages/webapp/__tests__/SourceRouting.spec.ts b/packages/webapp/__tests__/SourceRouting.spec.ts index 1dfab2d70a..829b155192 100644 --- a/packages/webapp/__tests__/SourceRouting.spec.ts +++ b/packages/webapp/__tests__/SourceRouting.spec.ts @@ -1,4 +1,4 @@ -import { gqlClient, ApiError } from '@dailydotdev/shared/src/graphql/common'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { getSquadStaticFields } from '@dailydotdev/shared/src/graphql/squads'; import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; import { getStaticProps as getSourceStaticProps } from '../pages/sources/[source]'; @@ -62,17 +62,6 @@ describe('source and squad route redirects', () => { }); it('redirects machine sources from /squads/[handle] to /sources/[handle]', async () => { - mockGetSquadStaticFields.mockRejectedValueOnce({ - response: { - errors: [ - { - extensions: { - code: ApiError.NotFound, - }, - }, - ], - }, - }); mockRequest.mockResolvedValueOnce({ source: { id: 'daily', @@ -99,5 +88,6 @@ describe('source and squad route redirects', () => { permanent: false, }, }); + expect(mockGetSquadStaticFields).not.toHaveBeenCalled(); }); }); diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index db3b672bb0..4a8cdeb092 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -502,6 +502,32 @@ export async function getServerSideProps({ }; try { + const sourceResult = await gqlClient.request(SOURCE_QUERY, { + id: handle, + }); + + if (isSourceUserSource(sourceResult.source)) { + setCacheHeader(); + + return { + redirect: { + destination: `/${sourceResult.source.id}`, + permanent: false, + }, + }; + } + + if (sourceResult.source?.type === SourceType.Machine) { + setCacheHeader(); + + return { + redirect: { + destination: `/sources/${handle}`, + permanent: false, + }, + }; + } + const referringUserPromise = userId && campaign ? gqlClient @@ -561,39 +587,6 @@ export async function getServerSideProps({ const errors = Object.values(ApiError); const errorCode = clientError?.response?.errors?.[0]?.extensions?.code; - if (errorCode === ApiError.NotFound) { - try { - const sourceResult = await gqlClient.request(SOURCE_QUERY, { - id: handle, - }); - - if (isSourceUserSource(sourceResult.source)) { - setCacheHeader(); - - return { - redirect: { - destination: `/${sourceResult.source.id}`, - permanent: false, - }, - }; - } - - if (sourceResult.source?.type === SourceType.Machine) { - setCacheHeader(); - - return { - redirect: { - destination: `/sources/${handle}`, - permanent: false, - }, - }; - } - } catch { - // Fall through to the existing not found behavior when the handle - // doesn't resolve to a non-squad source either. - } - } - if (errors.includes(errorCode)) { setCacheHeader(); From 927269fbccbdb75607871d3e372ceefd2b74b793 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 15 Apr 2026 13:48:03 +0200 Subject: [PATCH 3/3] test: fold source route coverage into squad page suite --- .../webapp/__tests__/SourceRouting.spec.ts | 93 ------------------- packages/webapp/__tests__/SquadFeedPage.tsx | 33 ++++++- 2 files changed, 32 insertions(+), 94 deletions(-) delete mode 100644 packages/webapp/__tests__/SourceRouting.spec.ts diff --git a/packages/webapp/__tests__/SourceRouting.spec.ts b/packages/webapp/__tests__/SourceRouting.spec.ts deleted file mode 100644 index 829b155192..0000000000 --- a/packages/webapp/__tests__/SourceRouting.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { getSquadStaticFields } from '@dailydotdev/shared/src/graphql/squads'; -import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; -import { getStaticProps as getSourceStaticProps } from '../pages/sources/[source]'; -import { getServerSideProps as getSquadServerSideProps } from '../pages/squads/[handle]'; - -jest.mock('@dailydotdev/shared/src/graphql/common', () => { - const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); - - return { - ...actual, - gqlClient: { - request: jest.fn(), - }, - }; -}); - -jest.mock('@dailydotdev/shared/src/graphql/squads', () => { - const actual = jest.requireActual('@dailydotdev/shared/src/graphql/squads'); - - return { - ...actual, - getSquadStaticFields: jest.fn(), - }; -}); - -const mockRequest = gqlClient.request as jest.MockedFunction< - typeof gqlClient.request ->; -const mockGetSquadStaticFields = - getSquadStaticFields as jest.MockedFunction; - -describe('source and squad route redirects', () => { - beforeEach(() => { - mockRequest.mockReset(); - mockGetSquadStaticFields.mockReset(); - }); - - it('redirects squad sources from /sources/[source] to /squads/[source]', async () => { - mockRequest.mockResolvedValueOnce({ - source: { - id: 'frontend', - name: 'Frontend', - image: 'https://daily.dev/frontend.png', - handle: 'frontend', - permalink: 'https://app.daily.dev/squads/frontend', - type: SourceType.Squad, - public: true, - }, - }); - - const result = await getSourceStaticProps({ - params: { source: 'frontend' }, - } as never); - - expect(result).toEqual({ - redirect: { - destination: '/squads/frontend', - permanent: false, - }, - }); - }); - - it('redirects machine sources from /squads/[handle] to /sources/[handle]', async () => { - mockRequest.mockResolvedValueOnce({ - source: { - id: 'daily', - name: 'daily.dev', - image: 'https://daily.dev/daily.png', - handle: 'daily', - permalink: 'https://app.daily.dev/sources/daily', - type: SourceType.Machine, - public: true, - }, - }); - - const setHeader = jest.fn(); - const result = await getSquadServerSideProps({ - params: { handle: 'daily' }, - query: {}, - res: { setHeader }, - } as never); - - expect(setHeader).toHaveBeenCalled(); - expect(result).toEqual({ - redirect: { - destination: '/sources/daily', - permanent: false, - }, - }); - expect(mockGetSquadStaticFields).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/webapp/__tests__/SquadFeedPage.tsx b/packages/webapp/__tests__/SquadFeedPage.tsx index 48caca498a..c9d07d015a 100644 --- a/packages/webapp/__tests__/SquadFeedPage.tsx +++ b/packages/webapp/__tests__/SquadFeedPage.tsx @@ -44,6 +44,7 @@ import { } from '@dailydotdev/shared/src/graphql/squads'; import { type SourceMember, + SourceType, type Squad, SourceMemberRole, SourcePermissions, @@ -57,8 +58,9 @@ import { CONTENT_PREFERENCE_STATUS_QUERY, ContentPreferenceType, } from '@dailydotdev/shared/src/graphql/contentPreference'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { useSquad } from '@dailydotdev/shared/src/hooks/squads/useSquad'; -import SquadPage from '../pages/squads/[handle]'; +import SquadPage, { getServerSideProps } from '../pages/squads/[handle]'; const defaultSquad: Squad = { ...generateTestSquad(), @@ -259,6 +261,35 @@ const renderComponent = ( }; describe('squad page', () => { + it('should redirect machine sources to the canonical sources route', async () => { + jest.spyOn(gqlClient, 'request').mockResolvedValueOnce({ + source: { + id: 'daily', + name: 'daily.dev', + image: 'https://daily.dev/daily.png', + handle: 'daily', + permalink: 'https://app.daily.dev/sources/daily', + type: SourceType.Machine, + public: true, + }, + } as Awaited>); + + const setHeader = jest.fn(); + const result = await getServerSideProps({ + params: { handle: 'daily' }, + query: {}, + res: { setHeader }, + } as never); + + expect(setHeader).toHaveBeenCalled(); + expect(result).toEqual({ + redirect: { + destination: '/sources/daily', + permanent: false, + }, + }); + }); + it('should request source feed', async () => { renderComponent(); await waitForNock();