diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..178839f4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,1315 @@ +--- +trigger: always_on +description: +globs: +--- + +## Project Structure + +- next.js react tailwind frontend `/web` + - broken down into pages, components, hooks, lib +- express node api server `/backend/api` +- one off scripts, like migrations `/backend/scripts` +- supabase postgres. schema in `/backend/supabase` + - supabase-generated types in `/backend/supabase/schema.ts` +- files shared between backend directories `/backend/shared` + - anything in `/backend` can import from `shared`, but not vice versa +- files shared between the frontend and backend in `/common` + - `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility + functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from + `/common`, but not vice versa. + +## Deployment + +- The project has both dev and prod environments. +- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform. +- Project ID is `compass-130ba`. + +## Code Guidelines + +--- + +Here's an example component from web in our style: + +```tsx +import clsx from 'clsx' +import Link from 'next/link' + +import {isAdminId, isModId} from 'common/envs/constants' +import {type Headline} from 'common/news' +import {EditNewsButton} from 'web/components/news/edit-news-button' +import {Carousel} from 'web/components/widgets/carousel' +import {useUser} from 'web/hooks/use-user' +import {track} from 'web/lib/service/analytics' +import {DashboardEndpoints} from 'web/components/dashboard/dashboard-page' +import {removeEmojis} from 'common/util/string' + +export function HeadlineTabs(props: { + headlines: Headline[] + currentSlug: string + endpoint: DashboardEndpoints + hideEmoji?: boolean + notSticky?: boolean + className?: string +}) { + const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = + props + const user = useUser() + + return ( +
+ + {headlines.map(({id, slug, title}) => ( + + ))} + {user && } + {user && (isAdminId(user.id) || isModId(user.id)) && ( + + )} + +
+ ) +} +``` + +--- + +We prefer to have many smaller components that each represent one logical unit, rather than one very large component +that does everything. Then we compose and reuse the components. + +It's best to export the main component at the top of the file. We also try to name the component the same as the file +name (headline-tabs.tsx) so that it's easy to find. + +Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached +by NextJS: + +```ts +import {api} from 'web/lib/api/api' + +// More imports... + +export async function getStaticProps() { + try { + const headlines = await api('headlines', {}) + return { + props: { + headlines, + revalidate: 30 * 60, // 30 minutes + }, + } + } catch (err) { + return {props: {headlines: []}, revalidate: 60} + } +} + +export default function Home(props: { headlines: Headline[] }) { ... +} +``` + +--- + +If we are calling the API on the client, prefer using the `useAPIGetter` hook: + +```ts +export const YourTopicsSection = (props: { + user: User + className?: string +}) => { + const {user, className} = props + const {data, refresh} = useAPIGetter('get-followed-groups', { + userId: user.id, + }) + const followedGroups = data?.groups ?? [] +... +``` + +This stores the result in memory, and allows you to call refresh() to get an updated version. + +--- + +We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache +data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and +appear to load instantly. + +Here's the definition of usePersistentInMemoryState: + +```ts +export const usePersistentInMemoryState = (initialValue: T, key: string) => { + const [state, setState] = useStateCheckEquality( + safeJsonParse(store[key]) ?? initialValue + ) + + useEffect(() => { + const storedValue = safeJsonParse(store[key]) ?? initialValue + setState(storedValue as T) + }, [key]) + + const saveState = useEvent((newState: T | ((prevState: T) => T)) => { + setState((prevState) => { + const updatedState = isFunction(newState) ? newState(prevState) : newState + store[key] = JSON.stringify(updatedState) + return updatedState + }) + }) + + return [state, saveState] as const +} +``` + +--- + +For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook: + +```ts +export function useApiSubscription(opts: SubscriptionOptions) { + useEffect(() => { + const ws = client + if (ws != null) { + if (opts.enabled ?? true) { + ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + return () => { + ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + } + } + } + }, [opts.enabled, JSON.stringify(opts.topics)]) +} +``` + +In `use-bets`, we have this hook to get live updates with useApiSubscription: + +```ts +export const useContractBets = ( + contractId: string, + opts?: APIParams<'bets'> & { enabled?: boolean } +) => { + const {enabled = true, ...apiOptions} = { + contractId, + ...opts, + } + const optionsKey = JSON.stringify(apiOptions) + + const [newBets, setNewBets] = usePersistentInMemoryState( + [], + `${optionsKey}-bets` + ) + + const addBets = (bets: Bet[]) => { + setNewBets((currentBets) => { + const uniqueBets = sortBy( + uniqBy([...currentBets, ...bets], 'id'), + 'createdTime' + ) + return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions)) + }) + } + + const isPageVisible = useIsPageVisible() + + useEffect(() => { + if (isPageVisible && enabled) { + api('bets', apiOptions).then(addBets) + } + }, [optionsKey, enabled, isPageVisible]) + + useApiSubscription({ + topics: [`contract/${contractId}/new-bet`], + onBroadcast: (msg) => { + addBets(msg.data.bets as Bet[]) + }, + enabled, + }) + + return newBets +} +``` + +--- + +Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts` + +```ts +export function broadcastUpdatedPrivateUser(userId: string) { + // don't send private user info because it's private and anyone can listen + broadcast(`private-user/${userId}`, {}) +} + +export function broadcastUpdatedUser(user: Partial & { id: string }) { + broadcast(`user/${user.id}`, {user}) +} + +export function broadcastUpdatedComment(comment: Comment) { + broadcast(`user/${comment.onUserId}/comment`, {comment}) +} +``` + +--- + +We have our scripts in the directory `/backend/scripts`. + +To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and +loads them into process.env. + +Example from `/backend/scripts/manicode.ts` + +```ts +import {runScript} from 'run-script' + +runScript(async ({pg}) => { + const userPrompt = process.argv[2] + await pg.none(...) +}) +``` + +Generally scripts should be run by me, especially if they modify backend state or schema. +But if you need to run a script, you can use `bun`. For example: + +```sh +bun run manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +if that doesn't work, try + +```sh +bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +--- + +Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in +`/common/src/api/schema.ts`. + +E.g. Here is a hypothetical bet schema: + +```ts + bet: { + method: 'POST', + authed +: + true, + returns +: + { + } + as + CandidateBet & {betId: string}, + props +: + z + .object({ + contractId: z.string(), + amount: z.number().gte(1), + replyToCommentId: z.string().optional(), + limitProb: z.number().gte(0.01).lte(0.99).optional(), + expiresAt: z.number().optional(), + // Used for binary and new multiple choice contracts (cpmm-multi-1). + outcome: z.enum(['YES', 'NO']).default('YES'), + //Multi + answerId: z.string().optional(), + dryRun: z.boolean().optional(), + }) + .strict(), +} +``` + +Then, we define the bet endpoint in `backend/api/src/place-bet.ts` + +```ts +export const placeBet: APIHandler<'bet'> = async (props, auth) => { + const isApi = auth.creds.kind === 'key' + return await betsQueue.enqueueFn( + () => placeBetMain(props, auth.uid, isApi), + [props.contractId, auth.uid] + ) +} +``` + +And finally, you need to register the handler in `backend/api/src/routes.ts` + +```ts +import {placeBet} from './place-bet' + +... + +const handlers = { + bet: placeBet, + ... +} +``` + +--- + +We have two ways to access our postgres database. + +```ts +import {db} from 'web/lib/supabase/db' + +db.from('profiles').select('*').eq('user_id', userId) +``` + +and + +```ts +import {createSupabaseDirectClient} from 'shared/supabase/init' + +const pg = createSupabaseDirectClient() +pg.oneOrNone>('select * from profiles where user_id = $1', [userId]) +``` + +The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query +and update the database directly from the frontend. + +`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, +using the pg-promise library. The client (code in web) does not have permission to do this. + +Another example using the direct client: + +```ts +export const getUniqueBettorIds = async ( + contractId: string, + pg: SupabaseDirectClient +) => { + const res = await pg.manyOrNone( + 'select distinct user_id from contract_bets where contract_id = $1', + [contractId] + ) + return res.map((r) => r.user_id as string) +} +``` + +(you may notice we write sql in lowercase) + +We have a few helper functions for updating and inserting data into the database. + +```ts +import { + buikInsert, + bulkUpdate, + bulkUpdateData, + bulkUpsert, + insert, + update, + updateData, +} from 'shared/supabase/utils' + +... + +const pg = createSupabaseDirectClient() + +// you are encouraged to use tryCatch for these +const {data, error} = await tryCatch( + insert(pg, 'profiles', {user_id: auth.uid, ...body}) +) + +if (error) throw APIError(500, 'Error creating profile: ' + error.message) + +await update(pg, 'profiles', 'user_id', {user_id: auth.uid, age: 99}) + +await updateData(pg, 'private_users', {id: userId, notifications: {...}}) +``` + +The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it +does is sanitize and output sql query strings. It has several helper functions including: + +- `select`: Specifies the columns to select +- `from`: Specifies the table to query +- `where`: Adds WHERE clauses +- `orderBy`: Specifies the order of results +- `limit`: Limits the number of results +- `renderSql`: Combines all parts into a final SQL string + +Example usage: + +```typescript +const query = renderSql( + select('distinct user_id'), + from('contract_bets'), + where('contract_id = ${id}', {id}), + orderBy('created_time desc'), + limitValue != null && limit(limitValue) +) + +const res = await pg.manyOrNone(query) +``` + +Use these functions instead of string concatenation. + +# Documentation for development + +> [!WARNING] +> TODO: This document is a work in progress. Please help us improve it! + +See those other useful documents as well: + +- [knowledge.md](knowledge.md) for high-level architecture and design decisions. +- [README.md](../backend/api/README.md) for the backend API +- [README.md](../backend/email/README.md) for the email routines and how to set up a local server for quick email + rendering +- [README.md](../web/README.md) for the frontend / web server +- [TESTING.md](TESTING.md) for testing guidance and direction + +### Adding a new profile field + +A profile field is any variable associated with a user profile, such as age, politics, diet, etc. You may want to add a +new profile field if it helps people find better matches. + +To do so, you can add code in a similar way as +in [this commit](https://github.com/CompassConnections/Compass/commit/940c1f5692f63bf72ddccd4ec3b00b1443801682) for the +`religion` field. If you also want people to filter by that profile field, you'll also need to add it to the search +filters, as done +in [this commit](https://github.com/CompassConnections/Compass/commit/a4bb184e95553184a4c8773d7896e4b570508fe5) (for the +`religion` field as well). + +Note that you will also need to add a column to the `profiles` table in the dev database before running the code; you +can do so via this SQL command (change the type if not `TEXT`): + +```sql +ALTER TABLE profiles + ADD COLUMN profile_field TEXT; +``` + +Store it in `add_profile_field.sql` in the [migrations](../backend/supabase/migrations) folder and +run [migrate.sh](../scripts/migrate.sh) from the root folder: + +```bash +./scripts/migrate.sh backend/supabase/migrations/add_profile_field.sql +``` + +Then sync the database types from supabase to the local files (which assist Typescript in typing): + +```bash +yarn regen-types dev +``` + +That's it! + +### Adding a new language + +Adding a new language is very easy, especially with translating tools like large language models (ChatGPT, etc.) which +you can use as first draft. + +- Add the language to the LOCALES dictionary in [constants.ts](../common/src/constants.ts) (the key is the locale code, + the value is the original language name (not in English)). +- Duplicate [fr.json](../web/messages/fr.json) and rename it to the locale code (e.g., `de.json` for German). Translate + all the strings in the new file (keep the keys identical). LLMs like ChatGPT may not be able to translate the whole + file in one go; try to copy-paste by batch of 300 lines and ask the LLM to + `translate the values of the json above to (keep the keys unchanged)`. In order to fit the bottom + navigation bar on mobile, make sure the values for those keys are less than 10 characters: "nav.home", " + nav.messages", "nav.more", "nav.notifs", "nav.people". +- Duplicate the [fr](../web/public/md/fr) folder and rename it to the locale code (e.g., `de` for German). Translate all + the markdown files in the new folder. To do so, you can copy-paste each file into an LLM and ask it to + `translate the markdown above to `. + +That's all, no code needed! + +# Testing + +### Why we test + +Testing exists to give us fast, reliable feedback about real behavior so we can ship with confidence. + +- Prevent regressions: Lock in correct behavior so future changes don’t silently break working features. +- Enable safe refactoring: A trustworthy suite lets us improve design without fear. +- Document intent: Tests act as living examples of how modules and components are expected to work. +- Catch edge cases early: Exercise unhappy paths, timeouts, and integration boundaries before production. +- Increase release confidence: Unit/integration tests plus a few critical E2E flows gate deployments. + +What testing is not + +- Not a replacement for monitoring, logging, or manual exploratory testing. +- Not a quest for 100% coverage—optimize for meaningful scenarios over raw numbers. + +How we apply it here + +- Unit and integration tests live in each package and run with Jest (see `jest.config.js`). +- Critical user journeys are covered by Playwright E2E tests under `tests/e2e` (see `playwright.config.ts`). + +### Test types at a glance + +This project uses three complementary test types. Use the right level for the job: + +- Unit tests + - Purpose: Verify a single function/module in isolation; fast, deterministic. + - Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`, + etc.). + - Runner: Jest (configured via root `jest.config.js`). + - Naming: `*.unit.test.ts` (or `.tsx` for React in `web`). + - When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies. + +- Integration tests + - Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without + spinning up the full app. + - Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`). + - Runner: Jest (configured via root `jest.config.js`). + - Naming: `*.integration.test.ts` (or `.tsx` for React in `web`). + - When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB, + component trees with providers. + +- End-to-End (E2E) tests + - Purpose: Validate real user flows across the full stack. + - Where: Top-level `tests/e2e` with separate areas for `web` and `backend`. + - Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`). + - Naming: `*.e2e.spec.ts`. + - When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments. + +Quick commands + +```bash +# Jest (unit + integration) +yarn test + +# Playwright (E2E) +yarn test:e2e +``` + +### Where to put test files + +```filetree +# Config +jest.config.js (for unit and integration tests) +playwright.config.ts (for e2e tests) + +# Top-level End-to-End (Playwright) +tests/ +├── e2e/ +│ ├── web/ +│ │ ├── pages/ +│ │ └── specs/ +│ │ └── example.e2e.spec.ts +│ └── backend/ +│ └── specs/ +│ └── api.e2e.spec.ts +└── reports/ + └── playwright-report/ + +# Package-level Unit & Integration (Jest) +backend/ +├── api/ +│ ├── src/ +│ └── tests/ +│ ├── unit/ +│ │ └── example.unit.test.ts +│ └── integration/ +│ └── example.integration.test.ts +├── email/ +│ └── tests/ +│ ├── unit/ +│ └── integration/ +└── shared/ + └── tests/ + ├── unit/ + └── integration/ + +common/ +└── tests/ + ├── unit/ + │ └── example.unit.test.ts + └── integration/ + └── example.integration.test.ts + +web/ +└── tests/ + ├── unit/ + │ └── example.unit.test.tsx + └── integration/ + └── example.integration.test.tsx +``` + +- End-to-End tests live under `tests/e2e` and are executed by Playwright. The root `playwright.config.ts` sets `testDir` + to `./tests/e2e`. +- Unit and integration tests live in each package’s `tests` folder and are executed by Jest via the root + `jest.config.js` projects array. +- Naming: + - Unit: `*.unit.test.ts` (or `.tsx` for React in `web`) + - Integration: `*.integration.test.ts` + - E2E (Playwright): `*.e2e.spec.ts` + +### Best Practices + +* Test Behavior, Not Implementation. Don’t test internal state or function calls unless you’re testing utilities or very + critical behavior. +* Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and + errors. +* Don’t Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response + schemas). +* Prefer userEvent Over fireEvent. It simulates real user interactions more accurately. +* Avoid Testing Next.js Internals . You don’t need to test getStaticProps, getServerSideProps themselves-test what they + render. +* Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases. +* Don't write end-to-end tests for features that change frequently unless absolutely necessary. + +### Jest Unit Testing Guide + +This guide provides guidelines and best practices for writing unit tests using Jest in this project. Following these +standards ensures consistency, maintainability, and comprehensive test coverage. + +#### Best Practices + +1. Isolate a function route - Each test should focus on one thing that can affect the function outcome +2. Keep tests independent - Tests should not rely on the execution order +3. Use meaningful assertions - Assert that functions are called, what they are called with and the results +4. Avoid testing implementation details - Focus on behavior and outputs +5. Mock external dependencies - Isolate the unit being tested + +#### Running Tests + +```bash +# Run all tests +yarn test + +# Run specific test file +yarn test path/to/test.unit.test.ts +``` + +#### Test Standards + +- Test file names should convey what to expect + - Follow the pattern: `.[unit,integration].test.ts`. Examples: + - filename.unit.test.ts + - filename.integration.test.ts +- Group related tests using describe blocks +- Use descriptive test names that explain the expected behavior. + - Follow the pattern: "should `expected behavior` [relevant modifier]". Examples: + - should `ban user` [with matching user id] + - should `ban user` [with matching user name] + +#### Mocking + +Mocking means replacing a real dependency (like a module, function, API client, timer, or browser API) with a +controllable test double so your test can run quickly and deterministically, without calling the real thing. In unit +tests we use mocks to isolate the unit under test; in integration tests we selectively mock only the expensive or +unstable edges (e.g., network, filesystem) while exercising real collaborations. + +What to mock vs not to mock + +- Mock: network/HTTP calls, databases/ORM clients, email/SMS providers, time and randomness (`Date`, timers, + `Math.random`), browser APIs that are hard to reproduce in Node (e.g., `localStorage`, `IntersectionObserver`). +- Prefer real: pure functions, small utilities, reducers/selectors, simple components; let their real logic run so tests + actually verify behavior. +- Don’t over-mock: If you mock everything, you only test your mocks. Keep integration tests that hit real boundaries + inside the process. + +Common test doubles + +- Stub: a function that returns a fixed value (no assertions on how it was used). +- Spy: records how a function was called (calls count/args); may optionally change behavior. +- Mock: a spy with expectations about how it must be called; in Jest, `jest.fn()` and `jest.spyOn()` produce mock + functions you can assert on. +- Fake: a lightweight in-memory implementation (e.g., an in-memory repo) used instead of the real service. + +Jest quick reference + +- Module mock: `jest.mock('path/to/module')` to replace all exports with mock functions. Control behavior with + `(exportedFn as jest.Mock).mockReturnValue(...)` or `.mockResolvedValue(...)` for async. +- Function mock: `const fn = jest.fn()`; set behavior with `.mockReturnValue`, `.mockImplementation`. +- Spy on existing method: `const spy = jest.spyOn(obj, 'method')` and optionally `spy.mockImplementation(...)`. +- Timers/time: `jest.useFakeTimers(); jest.setSystemTime(new Date('2024-01-01'));` and advance with + `jest.advanceTimersByTime(ms)`; finally `jest.useRealTimers()`. +- Clearing: `jest.clearAllMocks()` (between tests) vs `jest.resetAllMocks()` (reset implementations) vs + `jest.restoreAllMocks()` (restore spied originals). + +When writing mocks, assert both outcome and interaction: + +- Outcome: what your function returned or what side-effect occurred. +- Interaction: that dependencies were called the expected number of times and with the right arguments. + +Why mocking is important? + +- *Isolation* - Test your code independently of databases, APIs, and external systems. Tests only fail when your code + breaks, not when a server is down. +- *Speed* - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly + without waiting. +- *Control* - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce + with real systems. +- *Reliability* - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same + inputs = same results, every time. +- *Focus* - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to + actually work yet. + +###### Use `jest.mock()` + +Jest automatically hoists all `jest.mock()` calls to the top of the file before imports are evaluated. To maintain +clarity and align with best practices, explicitly place `jest.mock()` calls at the very top of the file. + +Modules mocked this way automatically return `undefined`, which is useful for simplifying tests. If a module or +function’s return value isn’t used, there’s no need to mock it further. + +```tsx +//Function and module mocks +jest.mock('path/to/module'); + +//Function and module imports +import {functionUnderTest} from "path/to/function" +import {module} from "path/to/module" + +describe('functionUnderTest', () => { + //Setup + beforeEach(() => { + //Run before each test + jest.resetAllMocks(); // Resets any mocks from previous tests + }); + afterEach(() => { + //Run after each test + jest.restoreAllMocks(); // Cleans up between tests + }); + + describe('when given valid input', () => { + it('should describe what is being tested', async () => { + //Arrange: Setup test data + const mockData = 'test'; + + //Act: Execute the function under test + const result = myFunction(mockData); + + //Assert: Verify the result + expect(result).toBe('expected'); + }); + }); + + describe('when an error occurs', () => { + //Test cases for errors + }); +}); +``` + +###### Modules + +When mocking modules it's important to verify what was returned if applicable, the amount of times said module was +called and what it was called with. + +```tsx +//functionFile.ts +import {module as mockedDep} from "path/to/module" + +export const functionUnderTest = async (param) => { + return await mockedDep(param); +}; +``` + +```tsx +//testFile.unit.test.ts +import {functionUnderTest} from "path/to/function"; +import {module as mockedDep} from "path/to/module"; + +jest.mock('path/to/module'); + +/** + * Inside the test case + * We create a mock for any information passed into the function that is being tested + * and if the function returns a result we create a mock to test the result + */ +const mockParam = "mockParam"; +const mockReturnValue = "mockModuleValue"; + +/** + * use .mockResolvedValue when handling async/await modules that return values + * use .mockReturnValue when handling non async/await modules that return values + */ +describe('functionUnderTest', () => { + it('returns mocked module value and calls dependency correctly', async () => { + (mockedDep as jest.Mock).mockResolvedValue(mockReturnValue); + + const result = await functionUnderTest(mockParam); + + expect(result).toBe(mockReturnValue); + expect(mockedDep).toHaveBeenCalledTimes(1); + expect(mockedDep).toHaveBeenCalledWith(mockParam); + }); +}); +``` + +Use namespace imports when you want to import everything a module exports under a single name. + +```tsx +//moduleFile.ts +export const module = async (param) => { + const value = "module" + return value +}; + +export const moduleTwo = async (param) => { + const value = "moduleTwo" + return value +}; +``` + +```tsx +//functionFile.ts +import {module, moduleTwo} from "path/to/module" + +export const functionUnderTest = async (param) => { + const mockValue = await moduleTwo(param) + const returnValue = await module(mockValue) + return returnValue; +}; +``` + +```tsx +//testFile.unit.test.ts +jest.mock('path/to/module'); + +/** + * This creates an object containing all named exports from ./path/to/module + */ +import * as mockModule from "path/to/module" + +(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue); +``` + +When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`. + +- `jest.mock()` mocks the entire module, which is ideal for external dependencies like Axios or database clients. +- `jest.spyOn()` mocks specific methods while keeping the real implementation for others. It can also be used to observe + how a real method is called without changing its behavior. + - also replaces the need to have `jest.mock()` at the top of the file. + +```tsx +//testFile.unit.test.ts +import * as mockModule from "path/to/module" + +//Mocking the return value of the module +jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue); + +//Spying on the module to check functionality +jest.spyOn(mockModule, 'module'); + +//You can assert the module functionality with both of the above exactly like you would if you used jest.mock() +expect(mockModule.module).toBeCalledTimes(1); +expect(mockModule.module).toBeCalledWith(mockParam); +``` + +###### Dependencies + +Mocking dependencies allows you to test `your code’s` logic in isolation, without relying on third-party services or +external functionality. + +```tsx +//functionFile.ts +import {dependency} from "path/to/dependency" + +export const functionUnderTest = async (param) => { + const depen = await dependency(); + const value = depen.module(); + + return value; +}; +``` + +```tsx +//testFile.unit.test.ts +jest.mock('path/to/dependency'); + +import {dependency} from "path/to/dependency" + +describe('functionUnderTest', () => { + /** + * Because the dependency has modules that are used we need to + * create a variable outside of scope that can be asserted on + */ + let mockDependency = {} as any; + beforeEach(() => { + mockDependency = { + module: jest.fn(), + }; + jest.resetAllMocks(); // Resets any mocks from previous tests + }); + afterEach(() => { + //Run after each test + jest.restoreAllMocks(); // Cleans up between tests + }); + + //Inside the test case + (mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue); + + expect(mockDependency.module).toBeCalledTimes(1); + expect(mockDependency.module).toBeCalledWith(mockParam); +}); +``` + +###### Error checking + +```tsx +//function.ts +const result = await functionName(param); + +if (!result) { + throw new Error(403, 'Error text', error); +} +; +``` + +```tsx +//testFile.unit.test.ts +const mockParam = {} as any; + +//This will check only the error message +expect(functionName(mockParam)) + .rejects + .toThrowError('Error text'); + +//This will check the complete error +try { + await functionName(mockParam); + fail('Should have thrown'); +} catch (error) { + const functionError = error as Error; + expect(functionError.code).toBe(403); + expect(functionError.message).toBe('Error text'); + expect(functionError.details).toBe(mockParam); + expect(functionError.name).toBe('Error'); +} +``` + +```tsx +//For console.error types +console.error('Error message', error); + +//Use spyOn to mock +const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { +}); + +expect(errorSpy).toHaveBeenCalledWith( + 'Error message', + expect.objectContaining({name: 'Error'}) //The error 'name' refers to the error type +); + +``` + +###### Mocking array return value + +```tsx +//arrayFile.ts +const exampleArray = [1, 2, 3, 4, 5]; + +const arrayResult = exampleArray.includes(2); +``` + +```tsx +//testFile.unit.test.ts + +//This will mock 'includes' for all arrays and force the return value to be true +jest.spyOn(Array.prototype, 'includes').mockReturnValue(true); + +// --- +//This will specify which 'includes' array to mock based on the args passed into the .includes() +jest.spyOn(Array.prototype, 'includes').mockImplementation(function (value) { + if (value === 2) { + return true; + } + return false; +}); +``` + +### Playwright (E2E) Testing Guide + +##### Usage + +```shell +# Run all tests +yarn test:e2e + +# Run with UI +yarn test:e2e:ui + +# Run specific test file +yarn test:e2e tests/e2e/auth.spec.ts + +# Reset test database +yarn test:db:reset +``` + +##### Component Selection Hierarchy + +Use this priority order for selecting elements in Playwright tests: + +1. Prefer `getByRole()` — use semantic roles that reflect how users interact + ```typescript + await page.getByRole('button', { name: 'Submit' }).click(); + ``` + If a meaningful ARIA role is not available, fall back to accessible text selectors (next point). + +2. Use accessible text selectors — when roles don't apply, target user-facing text + ```typescript + await page.getByLabel('Email').fill('user@example.com'); + await page.getByPlaceholder('Enter your name').fill('John'); + await page.getByText('Welcome back').isVisible(); + ``` + +3. Only use `data-testid` — when elements have no stable user-facing text + ```typescript + // For icons, toggles, or dynamic content without text + await page.getByTestId('menu-toggle').click(); + await page.getByTestId('loading-spinner').isVisible(); + ``` + +This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful. + +![Vercel](https://deploy-badge.vercel.app/vercel/compass) +[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml) +[![CD API](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml) +[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/CompassConnections/Compass/branch/main/graph/badge.svg)](https://codecov.io/gh/CompassConnections/Compass) +[![Users](https://img.shields.io/badge/Users-500%2B-blue?logo=myspace)](https://www.compassmeet.com/stats) + +# Compass + +This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming +deep, authentic 1-on-1 connections with clarity and efficiency. + +## Features + +- Extremely detailed profiles for deep connections +- Radically transparent: user base fully searchable +- Free, ad-free, not for profit (supported by donations) +- Created, hosted, maintained, and moderated by volunteers +- Open source +- Democratically governed + +You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and +the [FAQ](https://www.compassmeet.com/faq) as well. +A detailed description of the early vision is also available in +this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass +shifted to a more general audience). + +**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small +donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help +our community thrive! + +![Demo](https://raw.githubusercontent.com/CompassConnections/assets/refs/heads/main/assets/demo-2x.gif) + +## To Do + +No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. +Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The +goal is to make the platform better step by step, and every improvement counts. If you see something that could be +clearer, smoother, or more engaging, **please jump in**! + +The complete, official list of tasks is +available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are +working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue +for that task, assign it to yourself as well. + +To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do +so, use your preferred option: + +- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun) +- Email hello@compassmeet.com +- Raise an issue on GitHub + +If you want to add tasks without creating an account, you can simply email + +``` +a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com +``` + +Put the task title in the email subject and the task description in the email content. + +Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start, +just ask us on [Discord](https://discord.gg/8Vd7jzqjun). + +- [x] Authentication (user/password and Google Sign In) +- [x] Set up PostgreSQL in Production with supabase +- [x] Set up web hosting (vercel) +- [x] Set up backend hosting (google cloud) +- [x] Ask for detailed info upon registration (location, desired type of connection, prompt answers, gender, etc.) +- [x] Set up page listing all the profiles +- [x] Search through most profile variables +- [x] Set up chat / direct messaging +- [x] Set up domain name (compassmeet.com) +- [ ] Cover more than 90% with tests (unit, integration, e2e) +- [x] Add Android mobile app +- [ ] Add iOS mobile app +- [x] Add better onboarding (tooltips, modals, etc.) +- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.) +- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.) +- [ ] Add calendar integration and scheduling +- [ ] Add events (group calls, in-person meetups, etc.) + +#### Secondary To Do + +Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time +contributors. + +- [x] Clean up learn more page +- [x] Add dark theme +- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.) +- [ ] Add profile fields: conflict style +- [ ] Add profile fields: timezone +- [ ] Add translations: Italian, Dutch, Hindi, Chinese, etc. +- [x] Add filters to search through remaining profile fields (politics, religion, education level, etc.) +- [ ] Make the app more user-friendly and appealing (UI/UX) +- [ ] Clean up terms and conditions (convert to Markdown) +- [ ] Clean up privacy notice (convert to Markdown) +- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.) +- [x] Add email verification +- [x] Add password reset +- [x] Add automated welcome email +- [ ] Security audit and penetration testing +- [x] Make `deploy-api.sh` run automatically on push to `main` branch +- [x] Create settings page (change email, password, delete account, etc.) +- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.) +- [x] Improve loading sign (e.g., animation of a compass moving around) +- [x] Show compatibility score in profile page + +## Implementation + +The web app is coded in Typescript using React as front-end. It includes: + +- [Supabase](https://supabase.com/) for the PostgreSQL database +- [Google Cloud](https://console.cloud.google.com) for hosting the backend API +- [Firebase](https://firebase.google.com/) for authentication and media storage +- [Vercel](https://vercel.com/) for hosting the front-end + +## Development + +Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or +contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help. + +### Installation + +Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo +and navigating into it: + +```bash +git clone https://github.com//Compass.git +cd Compass +``` + +Install `yarn` (if not already installed): + +```bash +npm install --global yarn +``` + +Then, install the dependencies for this project: + +```bash +yarn install +``` + +### Tests + +Make sure the tests pass: + +```bash +yarn test +``` + +If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked. + +### Running the Development Server + +Start the development server: + +```bash +yarn dev +``` + +Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; +you should see a few synthetic profiles. + +Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it +would be great to improve that though! + +##### Full isolation + +`yarn dev` runs the app locally but uses the data from a shared remote database (Supabase) and authentication ( +Firebase). +If you want to avoid any conflict / break or simply have it run faster, run the app in full isolation locally: + +```bash +yarn test:db:reset # reset your local supabase +yarn isolated +``` + +### Contributing + +Now you can start contributing by making changes and submitting pull requests! + +We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant ( +GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically: + +- Components tab to see the React component tree and props (you need to install + the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension) +- Console tab for errors and logs +- Network tab to see the requests and responses +- Storage tab to see cookies and local storage + +You can also add `console.log()` statements in the code. + +If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web +components or improving wording in some pages. You can find those files in `web/public/md/`. + +##### Resources + +There is a lof of documentation in the [docs](docs) folder and across the repo, namely: + +- [Next.js.md](docs/Next.js.md) for core fundamentals about our web / page-rendering framework. +- [knowledge.md](docs/knowledge.md) for general information about the project structure. +- [development.md](docs/development.md) for additional instructions, such as adding new profile fields or languages. +- [TESTING.md](docs/TESTING.md) for how to write tests. +- [web](web) for the web. +- [backend/api](backend/api) for the backend API. +- [android](android) for the Android app. + +There are a lot of useful scripts you can use in the [scripts](scripts) folder. + +### Submission + +Add the original repo as upstream for syncing: + +```bash +git remote add upstream https://github.com/CompassConnections/Compass.git +``` + +Create a new branch for your changes: + +```bash +git checkout -b +``` + +Make changes, then stage and commit: + +```bash +git add . +git commit -m "Describe your changes" +``` + +Push branch to your fork: + +```bash +git push origin +``` + +Finally, open a Pull Request on GitHub from your `fork/` → `CompassConnections/Compass` main branch. + +### Environment Variables + +Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the +following services: email, geolocation. + +We can't make the following information public, for security and privacy reasons: + +- Database, otherwise anyone could access all the user data (including private messages) +- Firebase, otherwise anyone could remove users or modify the media files +- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the + bill. + +That's why we separate all those services between production and development environments, so that you can code freely +without impacting the functioning of the deployed platform. +Contributors should use the default keys for local development. Production uses a separate environment with stricter +rules and private keys that are not shared. + +If you do need one of the few remaining services, you need to set them up and store your own secrets as environment +variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.