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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "wallet_creator_follows" (
"id" TEXT NOT NULL,
"walletAddress" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "wallet_creator_follows_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "wallet_creator_follows_walletAddress_creatorId_key" ON "wallet_creator_follows"("walletAddress", "creatorId");

-- CreateIndex
CREATE INDEX "wallet_creator_follows_walletAddress_idx" ON "wallet_creator_follows"("walletAddress");

-- CreateIndex
CREATE INDEX "wallet_creator_follows_creatorId_idx" ON "wallet_creator_follows"("creatorId");
34 changes: 34 additions & 0 deletions src/modules/wallets/wallet-following.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// src/modules/wallets/wallet-following.controllers.ts
import { Request, Response, NextFunction } from 'express';
import { WalletFollowingParamsSchema } from './wallet-following.schemas';
import { fetchWalletFollowing } from './wallet-following.service';
import { sendSuccess, sendValidationError } from '../../utils/api-response.utils';

export async function httpGetWalletFollowing(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const parsedParams = WalletFollowingParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
sendValidationError(
res,
'Invalid wallet address',
parsedParams.error.issues.map(
(issue: { path: (string | number)[]; message: string }) => ({
field: 'address',
message: issue.message,
})
)
);
return;
}

const creators = await fetchWalletFollowing(parsedParams.data.address);

sendSuccess(res, creators);
} catch (error) {
next(error);
}
}
222 changes: 222 additions & 0 deletions src/modules/wallets/wallet-following.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Integration test: GET /api/v1/wallets/:address/following
//
// Verifies that the following list endpoint returns all creators a wallet
// follows, ordered alphabetically by display name. Also verifies that an
// empty array is returned for a wallet with no follows and that unauthenticated
// requests receive 401.

import supertest from 'supertest';
import { Keypair } from '@stellar/stellar-base';
import app from '../../app';
import { prisma } from '../../utils/prisma.utils';
import { signJwt } from '../../middlewares/jwt.middleware';

describe('GET /api/v1/wallets/:address/following', () => {
const PREFIX = 'wallet-following-test';
const walletA = Keypair.random();
const walletB = Keypair.random(); // wallet with no follows

const userIdA = `${PREFIX}-user-a`;
const userIdB = `${PREFIX}-user-b`;
const userIdC = `${PREFIX}-user-c`;
const userIdWalletA = `${PREFIX}-user-wallet-a`;
const userIdWalletB = `${PREFIX}-user-wallet-b`;

const creatorAId = `${PREFIX}-creator-a`; // 'Alice'
const creatorBId = `${PREFIX}-creator-b`; // 'Mike'
const creatorCId = `${PREFIX}-creator-c`; // 'Zara'

beforeAll(async () => {
// Seed users
await prisma.user.createMany({
data: [
{
id: userIdA,
email: `${userIdA}@example.test`,
passwordHash: 'hash',
firstName: 'Follow',
lastName: 'Test A',
},
{
id: userIdB,
email: `${userIdB}@example.test`,
passwordHash: 'hash',
firstName: 'Follow',
lastName: 'Test B',
},
{
id: userIdC,
email: `${userIdC}@example.test`,
passwordHash: 'hash',
firstName: 'Follow',
lastName: 'Test C',
},
{
id: userIdWalletA,
email: `${userIdWalletA}@example.test`,
passwordHash: 'hash',
firstName: 'Wallet',
lastName: 'A',
},
{
id: userIdWalletB,
email: `${userIdWalletB}@example.test`,
passwordHash: 'hash',
firstName: 'Wallet',
lastName: 'B',
},
],
skipDuplicates: true,
});

// Seed wallets
await prisma.stellarWallet.createMany({
data: [
{ userId: userIdWalletA, address: walletA.publicKey() },
{ userId: userIdWalletB, address: walletB.publicKey() },
],
skipDuplicates: true,
});

// Seed creator profiles (in reverse alphabetical order to test sorting)
await prisma.creatorProfile.createMany({
data: [
{
id: creatorCId,
userId: userIdC,
handle: `${PREFIX}-handle-c`,
displayName: 'Zara',
},
{
id: creatorAId,
userId: userIdA,
handle: `${PREFIX}-handle-a`,
displayName: 'Alice',
},
{
id: creatorBId,
userId: userIdB,
handle: `${PREFIX}-handle-b`,
displayName: 'Mike',
},
],
skipDuplicates: true,
});

// Seed follows: wallet A follows all three creators
await prisma.walletCreatorFollow.createMany({
data: [
{
walletAddress: walletA.publicKey(),
creatorId: creatorCId,
},
{
walletAddress: walletA.publicKey(),
creatorId: creatorAId,
},
{
walletAddress: walletA.publicKey(),
creatorId: creatorBId,
},
],
skipDuplicates: true,
});
});

afterAll(async () => {
// Cleanup in reverse dependency order
await prisma.walletCreatorFollow.deleteMany({
where: {
walletAddress: { in: [walletA.publicKey(), walletB.publicKey()] },
},
});
await prisma.creatorProfile.deleteMany({
where: { id: { in: [creatorAId, creatorBId, creatorCId] } },
});
await prisma.stellarWallet.deleteMany({
where: {
userId: { in: [userIdWalletA, userIdWalletB] },
},
});
await prisma.user.deleteMany({
where: {
id: {
in: [
userIdA,
userIdB,
userIdC,
userIdWalletA,
userIdWalletB,
],
},
},
});
});

// ── Authentication ───────────────────────────────────────────────────────

it('returns 401 for an unauthenticated request', async () => {
const res = await supertest(app).get(
`/api/v1/wallets/${walletA.publicKey()}/following`
);

expect(res.status).toBe(401);
});

// ── Alphabetical ordering ────────────────────────────────────────────────

it('returns creators in alphabetical order by display name', async () => {
const token = signJwt({
walletAddress: walletA.publicKey(),
sub: userIdWalletA,
});

const res = await supertest(app)
.get(`/api/v1/wallets/${walletA.publicKey()}/following`)
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);

const displayNames = res.body.data.map((c: any) => c.displayName);
expect(displayNames).toEqual(['Alice', 'Mike', 'Zara']);
});

// ── Completeness ─────────────────────────────────────────────────────────

it('returns all followed creators', async () => {
const token = signJwt({
walletAddress: walletA.publicKey(),
sub: userIdWalletA,
});

const res = await supertest(app)
.get(`/api/v1/wallets/${walletA.publicKey()}/following`)
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(3);

const ids = res.body.data.map((c: any) => c.id).sort();
expect(ids).toEqual(
[creatorAId, creatorBId, creatorCId].sort()
);
});

// ── Empty array for wallet with no follows ───────────────────────────────

it('returns an empty array for a wallet that follows no one', async () => {
const token = signJwt({
walletAddress: walletB.publicKey(),
sub: userIdWalletB,
});

const res = await supertest(app)
.get(`/api/v1/wallets/${walletB.publicKey()}/following`)
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
});
});
7 changes: 7 additions & 0 deletions src/modules/wallets/wallet-following.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// src/modules/wallets/wallet-following.schemas.ts
import { z } from 'zod';
import { StellarAddressSchema } from '../wallet/wallet.schemas';

export const WalletFollowingParamsSchema = z.object({
address: StellarAddressSchema,
});
42 changes: 42 additions & 0 deletions src/modules/wallets/wallet-following.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// src/modules/wallets/wallet-following.service.ts
import { prisma } from '../../utils/prisma.utils';

/**
* Returns all creators that the given wallet follows, ordered
* alphabetically by display name.
*/
export async function fetchWalletFollowing(walletAddress: string) {
const follows = await prisma.walletCreatorFollow.findMany({
where: { walletAddress },
select: {
creatorId: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});

if (follows.length === 0) {
return [];
}

const creatorIds = follows.map((f) => f.creatorId);

const creators = await prisma.creatorProfile.findMany({
where: { id: { in: creatorIds } },
select: {
id: true,
handle: true,
displayName: true,
avatarUrl: true,
},
});

// Sort by displayName alphabetically (case-insensitive).
creators.sort((a, b) =>
a.displayName.localeCompare(b.displayName, undefined, {
sensitivity: 'base',
})
);

return creators;
}
8 changes: 8 additions & 0 deletions src/modules/wallets/wallets.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,12 @@ walletsRouter.get(
*/
walletsRouter.get("/:address/holdings", httpGetWalletHoldings);

/**
* GET /api/v1/wallets/:address/following
*
* Returns all creators that the given wallet follows, ordered
* alphabetically by display name. Requires JWT authentication.
*/
walletsRouter.get('/:address/following', jwtAuth, httpGetWalletFollowing);

export default walletsRouter;
Loading