Clarify testing documentation: discourage monorepo-wide yarn test usage and add scoped testing examples.

This commit is contained in:
MartinBraquet
2026-07-29 20:35:47 +02:00
parent 93deed6130
commit 8bf0ec0590
7 changed files with 192 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/node'
import {type JSONContent} from '@tiptap/core'
import {type APIHandler} from 'api/helpers/endpoint'
import {
DIET_CHOICES,
@@ -16,6 +17,7 @@ import {
ROMANTIC_CHOICES,
} from 'common/choices'
import {OptionTableKey} from 'common/profiles/constants'
import {parseJsonContentToText} from 'common/util/parse'
import {compact} from 'lodash'
import {log} from 'shared/monitoring/log'
import {convertRow} from 'shared/profiles/supabase'
@@ -110,6 +112,8 @@ export type profileQueryType = {
skipCount?: boolean | undefined
last_active?: string | undefined
locale?: string | undefined
/** Which columns to return — see {@link ProfileProjection}. Defaults to `full`. */
projection?: ProfileProjection | undefined
} & {
[K in OptionTableKey]?: string[] | undefined
}
@@ -159,7 +163,69 @@ const arrayChoiceFields = [
{field: 'ethnicity', choices: RACE_CHOICES},
]
const EXCLUDED_PROFILE_COLS = new Set(['search_text', 'search_tsv'])
// Search-only columns. `bio_text`/`bio_tsv` were already stripped from the response by `convertRow`,
// but they were still being read out of Postgres on every query — `bio_tsv` is a tsvector roughly the
// size of the bio itself, so 20 of them per page is real DB→API transfer for bytes nobody reads.
const EXCLUDED_PROFILE_COLS = new Set(['search_text', 'search_tsv', 'bio_text', 'bio_tsv'])
/**
* Which profile columns to return.
*
* `full` is every column (minus {@link EXCLUDED_PROFILE_COLS}) and stays the default, since callers
* hand the row to code that expects a complete `Profile`. `card` returns only what the profile grid
* actually renders — the row is ~80 columns wide and the card reads about a quarter of them, so the
* rest is pure egress. See {@link PROFILE_CARD_COLS}.
*/
export type ProfileProjection = 'card' | 'full'
/**
* The profile columns the grid card in `web/components/profile-grid.tsx` reads, directly or through
* `ProfileAvatar` / `ProfileLocation` / `ProfileDetailRail` / `SendMessageButton`. Keep in sync with
* that component. `interests` / `causes` / `work` are not here — they come from their own joins.
*
* `bio` is selected but never sent: it is replaced by {@link toBioSnippet} before the response.
*/
const PROFILE_CARD_COLS = [
'id',
'user_id',
'created_time',
'age',
'gender',
'headline',
'bio',
'bio_length',
'keywords',
'pinned_url',
'city',
'country',
'region_code',
'occupation_title',
'diet',
'drinks_per_month',
'is_smoker',
'languages',
'mbti',
'pref_relation_styles',
]
/**
* A card shows at most 6 lines of headline + bio, and the widest card fits ~78 characters to a line.
* 600 leaves room to spare while cutting a long bio down to a fraction of its size.
*/
const BIO_SNIPPET_CHARS = 600
/**
* The card renders `parseJsonContentToText(profile.bio)` clamped to a few lines, so shipping the whole
* rich-text document is wasted. Computed here rather than read from the `bio_text` column: that column
* is built with `string_agg(DISTINCT ...)` for search, which reorders and dedupes the text nodes — fine
* for a tsvector, wrong for something a person reads.
*/
const toBioSnippet = (bio: unknown) => {
const text = parseJsonContentToText(bio as JSONContent)
.replace(/\s+/g, ' ')
.trim()
return text.length > BIO_SNIPPET_CHARS ? `${text.slice(0, BIO_SNIPPET_CHARS)}` : text
}
let profileCols: any
@@ -246,6 +312,7 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
skipId,
locale = 'en',
last_active,
projection = 'card',
} = props
log('get-profiles', {...props, userIds: userIds && `${userIds.length} ids`})
@@ -333,6 +400,16 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
)`
}
/** "Has this profile picked any option at all?" — the `array_length(...) > 0` test on the
* aggregate join, expressed so it does not need the join. */
function getManyToManyAnyClause(label: OptionTableKey) {
return `EXISTS (
SELECT 1 FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
WHERE profile_${label}.profile_id = profiles.id
)`
}
function getOptionClauseKeyword(label: OptionTableKey) {
return `EXISTS (
SELECT 1 FROM profile_${label}
@@ -626,12 +703,14 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
userIds && where(`profiles.user_id = any($(userIds))`, {userIds}),
// Deliberately EXISTS rather than `array_length(profile_work.work, 1) > 0`: reading the aggregate
// joins here would tie this filter to joins that are only there to shape the output.
!shortBio &&
where(
`bio_length >= ${100}
OR headline IS NOT NULL
OR array_length(profile_work.work, 1) > 0
OR array_length(profile_interests.interests, 1) > 0
OR ${getManyToManyAnyClause('work')}
OR ${getManyToManyAnyClause('interests')}
OR occupation_title IS NOT NULL
`,
),
@@ -675,7 +754,12 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
),
]
const profileCols = (await getProfileCols()) ?? 'profiles.*' // stored at module level
const profileCols =
projection === 'card'
? PROFILE_CARD_COLS.map((c) => `profiles.${c}`).join(', ')
: ((await getProfileCols()) ?? 'profiles.*') // stored at module level
// `users.name` / `users.username` feed `convertRow`, which folds them into the `user` object and
// then drops the top-level copies — they are not part of the response.
let selectCols = `${profileCols}, users.name, users.username, jsonb_build_object(
'id', users.id,
'name', users.name,
@@ -706,7 +790,14 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
const profiles = await pg.map(query, [], convertRow)
// console.debug('profiles:', profiles)
if (projection === 'card') {
for (const profile of profiles) {
profile.bio_snippet = toBioSnippet(profile.bio)
delete (profile as any).bio
}
}
console.debug('profiles:', profiles)
const countQuery = renderSql(select(`count(*) as count`), ...tableSelection, ...filters)

View File

@@ -32,6 +32,7 @@ describe('getProfiles', () => {
const props = {
limit: 2,
orderBy: 'last_online_time' as const,
projection: 'full' as const,
}
const mockReq = {} as any
@@ -56,6 +57,7 @@ describe('getProfiles', () => {
const props = {
limit: 2,
orderBy: 'last_online_time' as const,
projection: 'full' as const,
}
const mockReq = {} as any
const results: any = await profilesModule.getProfiles(props, mockReq, mockReq)
@@ -320,6 +322,77 @@ describe('loadProfiles', () => {
})
})
describe('when using the card projection', () => {
const bio = {
type: 'doc',
content: [{type: 'paragraph', content: [{type: 'text', text: 'Hello there'}]}],
}
it('selects only the columns the card renders', async () => {
;(mockPg.map as jest.Mock).mockResolvedValue([])
;(mockPg.one as jest.Mock).mockResolvedValue(0)
await profilesModule.loadProfiles({projection: 'card'})
const [query] = mockPg.map.mock.calls[0]
expect(query).toContain('profiles.headline')
expect(query).toContain('profiles.pinned_url')
// Heavy fields the card never reads.
expect(query).not.toContain('profiles.photo_urls')
expect(query).not.toContain('profiles.image_descriptions')
expect(query).not.toContain('profiles.links')
})
it('still filters out sparse profiles without that join', async () => {
;(mockPg.map as jest.Mock).mockResolvedValue([])
;(mockPg.one as jest.Mock).mockResolvedValue(0)
await profilesModule.loadProfiles({projection: 'card'})
const [query] = mockPg.map.mock.calls[0]
// The sparse-profile filter must not depend on the output joins.
expect(query).not.toContain('array_length(profile_work.work, 1)')
expect(query).toContain('FROM profile_work')
expect(query).toContain('bio_length >= 100')
})
it('keeps the work join for the full projection', async () => {
;(mockPg.map as jest.Mock).mockResolvedValue([])
;(mockPg.one as jest.Mock).mockResolvedValue(0)
await profilesModule.loadProfiles({projection: 'full'})
const [query] = mockPg.map.mock.calls[0]
expect(query).toContain('AS work')
})
it('replaces the rich-text bio with a plain-text snippet', async () => {
;(mockPg.map as jest.Mock).mockResolvedValue([{bio} as any])
;(mockPg.one as jest.Mock).mockResolvedValue(1)
const {profiles} = await profilesModule.loadProfiles({projection: 'card'})
expect(profiles[0].bio_snippet).toEqual('Hello there')
expect(profiles[0]).not.toHaveProperty('bio')
})
it('truncates a long bio', async () => {
const longBio = {
type: 'doc',
content: [{type: 'paragraph', content: [{type: 'text', text: 'a'.repeat(1000)}]}],
}
;(mockPg.map as jest.Mock).mockResolvedValue([{bio: longBio} as any])
;(mockPg.one as jest.Mock).mockResolvedValue(1)
const {profiles} = await profilesModule.loadProfiles({projection: 'card'})
expect(profiles[0].bio_snippet).toEqual(`${'a'.repeat(600)}`)
})
})
describe('when ordering by compatibility score', () => {
it('excludes profiles whose precomputed score was nulled out', async () => {
;(mockPg.map as jest.Mock).mockResolvedValue([])

View File

@@ -21,6 +21,10 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
}
delete profile.bio_text
delete profile.bio_tsv
// Already folded into `user` above, and not part of the `Profile` type — sending them twice is
// just egress.
delete profile.name
delete profile.username
return profile as Profile
}

View File

@@ -810,6 +810,10 @@ export const API = (_apiTypeCheck = {
.enum(['last_online_time', 'created_time', 'compatibility_score'])
.optional()
.default('last_online_time'),
// `card` trims the response to the fields the profile grid renders, and replaces the
// rich-text `bio` with a truncated `bio_snippet`. Defaults to `full` so existing API
// consumers keep getting the complete row.
projection: z.enum(['card', 'full']).optional().default('full'),
})
.strict(),
returns: {} as {

View File

@@ -4,7 +4,15 @@ import {User} from 'common/user'
export type ProfileRow = Row<'profiles'>
export type ProfileWithoutUser = ProfileRow & {[K in OptionTableKey]?: string[]}
export type Profile = ProfileWithoutUser & {user: User}
export type Profile = ProfileWithoutUser & {
user: User
/**
* Plain-text, truncated `bio`. List endpoints that only ever render a snippet send this instead of
* the full rich-text `bio` document — see the `card` projection in the backend's `get-profiles`.
* When it is set, `bio` is absent.
*/
bio_snippet?: string
}
export const getProfileRowWithFrontendSupabase = async (
userId: string,

View File

@@ -537,10 +537,12 @@ function ProfilePreview(props: {
const headline = showHeadline !== false ? profile.headline?.trim() : undefined
const bioText = useMemo(() => {
if (showBio === false) return ''
// The `card` projection sends a pre-truncated snippet instead of the rich-text document.
if (profile.bio_snippet !== undefined) return profile.bio_snippet
return parseJsonContentToText(profile.bio as JSONContent)
.replace(/\s+/g, ' ')
.trim()
}, [profile.bio, showBio])
}, [profile.bio, profile.bio_snippet, showBio])
// Keep the total amount of text roughly constant per card: the longer the headline, the fewer
// bio lines we show. A card with no headline gives the whole budget to the bio, and vice versa.

View File

@@ -143,6 +143,8 @@ export function ProfilesHome() {
limit: 20,
compatibleWithUserId: user?.id,
locale,
// The grid only renders a card per profile; the full row is several times the size.
projection: 'card',
...filters,
})
if (!!profiles?.length && isEqual(getProfilesArgs, args)) {
@@ -213,6 +215,7 @@ export function ProfilesHome() {
compatibleWithUserId: user?.id,
after: lastProfile?.id.toString(),
locale,
projection: 'card',
...filters,
}) as any,
)