Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/backend/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Generic configuration for backend development
# This file is tracked in git and should NOT contain production secrets

PORT=8080
DATABASE_URL=postgresql://postgres:password@db.localtest.me:5432/main
REDIS_URL=redis://localhost:6379

# Must match the value in packages/web/.env.local so auth token validation works
NEXTAUTH_SECRET=68cJgCDE39gaXwi8LTVW4WioyhGxwcAd
2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "NODE_ENV=development DOTENV_CONFIG_PATH=.env.local tsx watch src/index.ts",
"dev": "NODE_ENV=development DOTENV_CONFIG_PATH=.env.development tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "tsx src/index.ts",
Expand Down
245 changes: 245 additions & 0 deletions packages/backend/src/__tests__/setter-follows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// All mock variables must be inside vi.hoisted() to avoid "Cannot access before initialization" errors
const { mockDb } = vi.hoisted(() => {
const mockDb = {
execute: vi.fn(),
select: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
};

return { mockDb };
});

vi.mock('../db/client', () => ({
db: mockDb,
}));

vi.mock('../events/index', () => ({
publishSocialEvent: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('../utils/rate-limiter', () => ({
checkRateLimit: vi.fn(),
}));

vi.mock('../utils/redis-rate-limiter', () => ({
checkRateLimitRedis: vi.fn(),
}));

vi.mock('../db/queries/util/table-select', () => ({
getBoardTables: vi.fn().mockReturnValue({
climbs: { uuid: 'uuid', layoutId: 'layoutId', boardType: 'boardType', setterUsername: 'setterUsername', name: 'name', description: 'description', frames: 'frames', createdAt: 'createdAt' },
climbStats: { climbUuid: 'climbUuid', boardType: 'boardType', angle: 'angle', ascensionistCount: 'ascensionistCount', qualityAverage: 'qualityAverage', difficultyAverage: 'difficultyAverage', displayDifficulty: 'displayDifficulty', benchmarkDifficulty: 'benchmarkDifficulty' },
difficultyGrades: { boardType: 'boardType', difficulty: 'difficulty', boulderName: 'boulderName' },
}),
isValidBoardName: vi.fn().mockReturnValue(true),
}));

vi.mock('../db/queries/util/hold-state', () => ({
convertLitUpHoldsStringToMap: vi.fn().mockReturnValue([{}]),
}));

import type { ConnectionContext } from '@boardsesh/shared-schema';
import { setterFollowMutations } from '../graphql/resolvers/social/setter-follows';

function makeCtx(overrides: Partial<ConnectionContext> = {}): ConnectionContext {
return {
connectionId: 'conn-1',
isAuthenticated: true,
userId: 'user-123',
sessionId: null,
boardPath: null,
controllerId: null,
controllerApiKey: null,
...overrides,
} as ConnectionContext;
}

/**
* Create a chainable mock object that resolves at any point.
* Each method returns the same chain, and the chain is also a thenable
* that resolves with the provided value (for await).
*/
function createMockChain(resolveValue: unknown = []): Record<string, unknown> {
const chain: Record<string, unknown> = {};
const methods = [
'select', 'from', 'where', 'leftJoin', 'innerJoin',
'groupBy', 'orderBy', 'limit', 'offset',
'insert', 'values', 'onConflictDoNothing', 'returning',
'delete', 'update', 'set',
];

// Make the chain a thenable (for destructuring awaits like `const [x] = await db.select()...`)
chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(resolveValue).then(resolve);

for (const method of methods) {
chain[method] = vi.fn((..._args: unknown[]) => chain);
}

return chain;
}

describe('followSetter mutation', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should throw for unauthenticated users', async () => {
const ctx = makeCtx({ isAuthenticated: false });
await expect(
setterFollowMutations.followSetter(null, { input: { setterUsername: 'setter1' } }, ctx),
).rejects.toThrow('Authentication required');
});

it('should throw if setter does not exist', async () => {
const ctx = makeCtx();

// select().from().where().limit() → [{ count: 0 }]
const existsChain = createMockChain([{ count: 0 }]);
mockDb.select.mockReturnValueOnce(existsChain);

await expect(
setterFollowMutations.followSetter(null, { input: { setterUsername: 'nonexistent' } }, ctx),
).rejects.toThrow('Setter not found');
});

it('should insert follow and return true', async () => {
const ctx = makeCtx();

// 1. Setter exists check → count: 1
const existsChain = createMockChain([{ count: 1 }]);
mockDb.select.mockReturnValueOnce(existsChain);

// 2. Insert follow → returns row (new follow)
const insertChain = createMockChain([{ id: 1, followerId: 'user-123', setterUsername: 'setter1' }]);
mockDb.insert.mockReturnValueOnce(insertChain);

// 3. Check linked user → no linked users
const linkedChain = createMockChain([]);
mockDb.select.mockReturnValueOnce(linkedChain);

const result = await setterFollowMutations.followSetter(
null,
{ input: { setterUsername: 'setter1' } },
ctx,
);

expect(result).toBe(true);
expect(mockDb.insert).toHaveBeenCalled();
});

it('should handle idempotent follow (onConflictDoNothing returns empty)', async () => {
const ctx = makeCtx();

// Setter exists
const existsChain = createMockChain([{ count: 1 }]);
mockDb.select.mockReturnValueOnce(existsChain);

// Insert returns empty (conflict, already following)
const insertChain = createMockChain([]);
mockDb.insert.mockReturnValueOnce(insertChain);

const result = await setterFollowMutations.followSetter(
null,
{ input: { setterUsername: 'setter1' } },
ctx,
);

expect(result).toBe(true);
// No additional insert for user_follows since result was empty
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});

it('should create user_follows when setter has linked Boardsesh account', async () => {
const ctx = makeCtx();

// 1. Setter exists
const existsChain = createMockChain([{ count: 1 }]);
mockDb.select.mockReturnValueOnce(existsChain);

// 2. Insert follow returns new row
const insertChain = createMockChain([{ id: 1 }]);
mockDb.insert.mockReturnValueOnce(insertChain);

// 3. Linked user found
const linkedChain = createMockChain([{ userId: 'linked-user-456' }]);
mockDb.select.mockReturnValueOnce(linkedChain);

// 4. user_follows insert
const userFollowInsertChain = createMockChain(undefined);
mockDb.insert.mockReturnValueOnce(userFollowInsertChain);

const result = await setterFollowMutations.followSetter(
null,
{ input: { setterUsername: 'setter1' } },
ctx,
);

expect(result).toBe(true);
// Insert called twice: once for setter_follows, once for user_follows
expect(mockDb.insert).toHaveBeenCalledTimes(2);
});
});

describe('unfollowSetter mutation', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should throw for unauthenticated users', async () => {
const ctx = makeCtx({ isAuthenticated: false });
await expect(
setterFollowMutations.unfollowSetter(null, { input: { setterUsername: 'setter1' } }, ctx),
).rejects.toThrow('Authentication required');
});

it('should delete follow and return true', async () => {
const ctx = makeCtx();

// Delete setter_follows
const deleteChain = createMockChain(undefined);
mockDb.delete.mockReturnValueOnce(deleteChain);

// Check linked user → no linked users
const linkedChain = createMockChain([]);
mockDb.select.mockReturnValueOnce(linkedChain);

const result = await setterFollowMutations.unfollowSetter(
null,
{ input: { setterUsername: 'setter1' } },
ctx,
);

expect(result).toBe(true);
expect(mockDb.delete).toHaveBeenCalledTimes(1);
});

it('should also delete user_follows when setter has linked account', async () => {
const ctx = makeCtx();

// Delete setter_follows
const deleteChain = createMockChain(undefined);
mockDb.delete.mockReturnValueOnce(deleteChain);

// Check linked user → found
const linkedChain = createMockChain([{ userId: 'linked-user-456' }]);
mockDb.select.mockReturnValueOnce(linkedChain);

// Delete user_follows
const deleteUserFollowChain = createMockChain(undefined);
mockDb.delete.mockReturnValueOnce(deleteUserFollowChain);

const result = await setterFollowMutations.unfollowSetter(
null,
{ input: { setterUsername: 'setter1' } },
ctx,
);

expect(result).toBe(true);
// Delete called twice: setter_follows and user_follows
expect(mockDb.delete).toHaveBeenCalledTimes(2);
});
});
Loading
Loading