mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
Clarify testing documentation: discourage monorepo-wide yarn test usage and add scoped testing examples.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user