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
117 changes: 117 additions & 0 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,120 @@ describe('PUT /api/cards/:id/default', () => {
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()
})
})
63 changes: 44 additions & 19 deletions apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify'
import type { Prisma } from '@prisma/client'
import type { FastifyInstance } from 'fastify'

export async function listCards(app: FastifyInstance, userId: string) {

Check warning on line 4 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const cards = await app.prisma.card.findMany({
where: { userId },
take: 50,
Expand All @@ -12,10 +12,12 @@
return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }))
}

export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {

Check warning on line 15 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
if (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 (ownedLinks.length !== body.linkIds.length) {
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
}
}

const cardCount = await app.prisma.card.count({ where: { userId } })
Expand All @@ -33,40 +35,61 @@
return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
}

export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) {

Check warning on line 38 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
if (!existing) return null
if (!existing) {
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 } })
if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
const linkIds = body.linkIds

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) => {
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 })) })
await tx.cardLink.createMany({
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' } } } })
const updated = await app.prisma.card.findUnique({
where: { id },
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
})
return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) }
}

export async function deleteCard(app: FastifyInstance, userId: string, id: string) {

Check warning on line 82 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const existing = await tx.card.findFirst({ where: { id, userId } })
if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })
if (!existing) {
return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })
}

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

if (existing.isDefault) {
const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } })
Expand All @@ -80,14 +103,16 @@
})
}

export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) {

Check warning on line 106 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
if (!existing) return null
if (!existing) {
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' }
}
}
Loading