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
119 changes: 118 additions & 1 deletion apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,4 +440,121 @@ describe('PUT /api/cards/:id/default', () => {
expect(mockPrisma.card.updateMany).toHaveBeenCalled();
expect(mockPrisma.card.update).toHaveBeenCalled();
});
});
});

// ─────────────────────────────────────────────────────────────────────────────
// PUT /api/cards/:id — atomicity of combined title + linkIds update (#437)
// ─────────────────────────────────────────────────────────────────────────────

describe('PUT /api/cards/:id — atomicity of combined title + linkIds update', () => {
beforeEach(() => {
vi.clearAllMocks()
wireTransaction()
})

it('commits both title and links in a single transaction on success', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard)
mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }])
mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'New Title' })
mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 })
mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 })
mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, title: 'New Title', cardLinks: [] })

const app = await buildApp()
const res = await app.inject({
method: 'PUT',
url: `/api/cards/${CARD_ID}`,
payload: { title: 'New Title', linkIds: [OWNED_LINK_ID] },
})

expect(res.statusCode).toBe(200)
// Both mutations must be inside one transaction, not two separate calls
expect(mockPrisma.$transaction).toHaveBeenCalledOnce()
expect(mockPrisma.card.update).toHaveBeenCalledWith({ where: { id: CARD_ID }, data: { title: 'New Title' } })
expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } })
expect(mockPrisma.cardLink.createMany).toHaveBeenCalled()
})

it('does not commit the title when the linkIds createMany fails (full rollback)', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard)
mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }])
// card.update (title) succeeds inside tx, but createMany blows up
mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'New Title' })
mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 })
mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint violation'))

const app = await buildApp()
const res = await app.inject({
method: 'PUT',
url: `/api/cards/${CARD_ID}`,
payload: { title: 'New Title', linkIds: [OWNED_LINK_ID] },
})

expect(res.statusCode).toBe(500)
// The transaction rolled back — the final read must not have been attempted
expect(mockPrisma.card.findUnique).not.toHaveBeenCalled()
// Confirm both operations ran inside the same tx (the DB undoes them together)
expect(mockPrisma.card.update).toHaveBeenCalled()
expect(mockPrisma.cardLink.createMany).toHaveBeenCalled()
})

it('returns 403 and opens no transaction when a linkId fails ownership validation', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard)
// Ownership check returns empty — foreign linkId
mockPrisma.platformLink.findMany.mockResolvedValue([])

const app = await buildApp()
const res = await app.inject({
method: 'PUT',
url: `/api/cards/${CARD_ID}`,
payload: { title: 'New Title', linkIds: [FOREIGN_LINK_ID] },
})

expect(res.statusCode).toBe(403)
expect(res.json().error).toBe('One or more links do not belong to your account')
// No transaction must have been opened — no writes of any kind
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
expect(mockPrisma.card.update).not.toHaveBeenCalled()
expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled()
})

it('applies only the title update when linkIds is absent', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard)
mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'Title Only' })
mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, title: 'Title Only', cardLinks: [] })

const app = await buildApp()
const res = await app.inject({
method: 'PUT',
url: `/api/cards/${CARD_ID}`,
payload: { title: 'Title Only' },
})

expect(res.statusCode).toBe(200)
expect(mockPrisma.$transaction).toHaveBeenCalledOnce()
expect(mockPrisma.card.update).toHaveBeenCalledWith({ where: { id: CARD_ID }, data: { title: 'Title Only' } })
expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled()
expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled()
})

it('applies only link replacement when title is absent', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard)
mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }])
mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 })
mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 })
mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, cardLinks: [] })

const app = await buildApp()
const res = await app.inject({
method: 'PUT',
url: `/api/cards/${CARD_ID}`,
payload: { linkIds: [OWNED_LINK_ID] },
})

expect(res.statusCode).toBe(200)
expect(mockPrisma.$transaction).toHaveBeenCalledOnce()
expect(mockPrisma.card.update).not.toHaveBeenCalled()
expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } })
expect(mockPrisma.cardLink.createMany).toHaveBeenCalled()
})
})
78 changes: 35 additions & 43 deletions apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,9 @@ export async function listCards(app: FastifyInstance, userId: string): Promise<C

export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise<CardResponse> {
if (body.linkIds.length > 0) {
const ownedLinks = await app.prisma.platformLink.findMany({
where: { id: { in: body.linkIds }, userId },
select: { id: true },
});

const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })
if (ownedLinks.length !== body.linkIds.length) {
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
}
}

Expand Down Expand Up @@ -81,66 +77,62 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t
throw new Error('Failed to create card after retrying serialization conflicts');
}

export async function updateCard(
app: FastifyInstance,
userId: string,
id: string,
body: { title?: string; linkIds?: string[] },
): Promise<CardResponse | null> {
const existing = await app.prisma.card.findFirst({ where: { id, userId } });
export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }): Promise<CardResponse | null> {
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
if (!existing) {
return null;
return null
}

if (body.title) {
await app.prisma.card.update({ where: { id }, data: { title: body.title } });
if (body.linkIds && body.linkIds.length > 0) {
const ownedLinks = await app.prisma.platformLink.findMany({
where: { id: { in: body.linkIds }, userId },
select: { id: true },
})
if (ownedLinks.length !== body.linkIds.length) {
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
}
}

if (body.linkIds) {
if (body.linkIds.length > 0) {
const ownedLinks = await app.prisma.platformLink.findMany({
where: { id: { in: body.linkIds }, userId },
select: { id: true },
});
const linkIds = body.linkIds

if (ownedLinks.length !== body.linkIds.length) {
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });
}
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
if (body.title) {
await tx.card.update({ where: { id }, data: { title: body.title } })
}

const linkIds = body.linkIds;
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.cardLink.deleteMany({ where: { cardId: id } });
if (linkIds !== undefined) {
await tx.cardLink.deleteMany({ where: { cardId: id } })
if (linkIds.length > 0) {
await tx.cardLink.createMany({
data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })),
});
data: linkIds.map((linkId, index) => ({
cardId: id,
platformLinkId: linkId,
displayOrder: index,
})),
})
}
});
}
}
})

const updated = (await app.prisma.card.findUnique({
where: { id },
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
})) as unknown as RawCard | null;

if (!updated) {
return null;
}

if (!updated) {return null;}
return mapCard(updated);
}

export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise<null> {
return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const existing = await tx.card.findFirst({ where: { id, userId } });
const existing = await tx.card.findFirst({ where: { id, userId } })
if (!existing) {
throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' });
throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })
}

const userCardCount = await tx.card.count({ where: { userId } });
const userCardCount = await tx.card.count({ where: { userId } })
if (userCardCount <= 1) {
throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' });
throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })
}

if (existing.isDefault) {
Expand All @@ -160,15 +152,15 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin
}

export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> {
const existing = await app.prisma.card.findFirst({ where: { id, userId } });
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
if (!existing) {
return null;
return null
}

await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.card.updateMany({ where: { userId }, data: { isDefault: false } });
await tx.card.update({ where: { id }, data: { isDefault: true } });
});

return { message: 'Default card updated' };
return { message: 'Default card updated' }
}