diff --git a/packages/shared-ui/src/navigation/TagStrip.svelte b/packages/shared-ui/src/navigation/TagStrip.svelte index e634177e1..965c5e950 100644 --- a/packages/shared-ui/src/navigation/TagStrip.svelte +++ b/packages/shared-ui/src/navigation/TagStrip.svelte @@ -147,6 +147,21 @@ overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; + /* Fade edges to indicate scrollable content */ + mask-image: linear-gradient( + to right, + transparent 0%, + black 2rem, + black calc(100% - 2rem), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 2rem, + black calc(100% - 2rem), + transparent 100% + ); } .tag-strip-container::-webkit-scrollbar { diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.controller.spec.ts b/services/mana-core-auth/src/tag-groups/tag-groups.controller.spec.ts new file mode 100644 index 000000000..01ac7eb3d --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/tag-groups.controller.spec.ts @@ -0,0 +1,207 @@ +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { TagGroupsController } from './tag-groups.controller'; +import { TagGroupsService } from './tag-groups.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; + +describe('TagGroupsController', () => { + let controller: TagGroupsController; + let tagGroupsService: jest.Mocked; + + const mockUser: CurrentUserData = { + userId: 'test-user-id', + email: 'test@example.com', + role: 'user', + }; + + const mockTagGroup = { + id: 'group-1', + userId: 'test-user-id', + name: 'Kategorien', + color: '#3B82F6', + icon: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockTagGroupsServiceValue = { + findByUserId: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + reorder: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TagGroupsController], + providers: [ + { + provide: TagGroupsService, + useValue: mockTagGroupsServiceValue, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + controller = module.get(TagGroupsController); + tagGroupsService = module.get(TagGroupsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================ + // GET /tag-groups + // ============================================================================ + + describe('GET /tag-groups', () => { + it('should return all tag groups for the authenticated user', async () => { + const groups = [ + mockTagGroup, + { ...mockTagGroup, id: 'group-2', name: 'Projekte', sortOrder: 1 }, + ]; + + tagGroupsService.findByUserId.mockResolvedValue(groups); + + const result = await controller.findAll(mockUser); + + expect(result).toEqual(groups); + expect(tagGroupsService.findByUserId).toHaveBeenCalledWith('test-user-id'); + }); + + it('should return empty array when user has no groups', async () => { + tagGroupsService.findByUserId.mockResolvedValue([]); + + const result = await controller.findAll(mockUser); + + expect(result).toEqual([]); + }); + }); + + // ============================================================================ + // POST /tag-groups + // ============================================================================ + + describe('POST /tag-groups', () => { + it('should create a new tag group and return it', async () => { + const createDto = { name: 'Neues Projekt', color: '#10B981' }; + const createdGroup = { ...mockTagGroup, ...createDto, id: 'group-new' }; + + tagGroupsService.create.mockResolvedValue(createdGroup); + + const result = await controller.create(mockUser, createDto); + + expect(result).toEqual(createdGroup); + expect(tagGroupsService.create).toHaveBeenCalledWith('test-user-id', createDto); + }); + + it('should propagate ConflictException for duplicate group name', async () => { + const createDto = { name: 'Kategorien' }; + + tagGroupsService.create.mockRejectedValue( + new ConflictException('Tag group "Kategorien" already exists') + ); + + await expect(controller.create(mockUser, createDto)).rejects.toThrow(ConflictException); + }); + }); + + // ============================================================================ + // PUT /tag-groups/reorder + // ============================================================================ + + describe('PUT /tag-groups/reorder', () => { + it('should reorder tag groups and return updated list', async () => { + const reorderedGroups = [ + { ...mockTagGroup, id: 'group-2', sortOrder: 0 }, + { ...mockTagGroup, id: 'group-1', sortOrder: 1 }, + ]; + + tagGroupsService.reorder.mockResolvedValue(reorderedGroups); + + const result = await controller.reorder(mockUser, { ids: ['group-2', 'group-1'] }); + + expect(result).toEqual(reorderedGroups); + expect(tagGroupsService.reorder).toHaveBeenCalledWith('test-user-id', ['group-2', 'group-1']); + }); + + it('should propagate NotFoundException when a group ID is invalid', async () => { + tagGroupsService.reorder.mockRejectedValue( + new NotFoundException('One or more tag groups not found') + ); + + await expect( + controller.reorder(mockUser, { ids: ['group-1', 'nonexistent'] }) + ).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================================================ + // PUT /tag-groups/:id + // ============================================================================ + + describe('PUT /tag-groups/:id', () => { + it('should update a tag group and return the updated version', async () => { + const updateDto = { name: 'Umbenannt', color: '#EF4444' }; + const updatedGroup = { ...mockTagGroup, ...updateDto }; + + tagGroupsService.update.mockResolvedValue(updatedGroup); + + const result = await controller.update(mockUser, 'group-1', updateDto); + + expect(result).toEqual(updatedGroup); + expect(tagGroupsService.update).toHaveBeenCalledWith('group-1', 'test-user-id', updateDto); + }); + + it('should propagate NotFoundException when group does not exist', async () => { + const updateDto = { name: 'Updated' }; + + tagGroupsService.update.mockRejectedValue(new NotFoundException('Tag group not found')); + + await expect(controller.update(mockUser, 'nonexistent', updateDto)).rejects.toThrow( + NotFoundException + ); + }); + + it('should propagate ConflictException when renaming to an existing name', async () => { + const updateDto = { name: 'Kategorien' }; + + tagGroupsService.update.mockRejectedValue( + new ConflictException('Tag group "Kategorien" already exists') + ); + + await expect(controller.update(mockUser, 'group-2', updateDto)).rejects.toThrow( + ConflictException + ); + }); + }); + + // ============================================================================ + // DELETE /tag-groups/:id + // ============================================================================ + + describe('DELETE /tag-groups/:id', () => { + it('should delete a tag group and return void', async () => { + tagGroupsService.delete.mockResolvedValue(undefined); + + const result = await controller.delete(mockUser, 'group-1'); + + expect(result).toBeUndefined(); + expect(tagGroupsService.delete).toHaveBeenCalledWith('group-1', 'test-user-id'); + }); + + it('should propagate NotFoundException when group does not exist', async () => { + tagGroupsService.delete.mockRejectedValue(new NotFoundException('Tag group not found')); + + await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/services/mana-core-auth/src/tag-links/tag-links.controller.spec.ts b/services/mana-core-auth/src/tag-links/tag-links.controller.spec.ts new file mode 100644 index 000000000..f2449f4e5 --- /dev/null +++ b/services/mana-core-auth/src/tag-links/tag-links.controller.spec.ts @@ -0,0 +1,263 @@ +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { TagLinksController } from './tag-links.controller'; +import { TagLinksService } from './tag-links.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; + +describe('TagLinksController', () => { + let controller: TagLinksController; + let tagLinksService: jest.Mocked; + + const mockUser: CurrentUserData = { + userId: 'test-user-id', + email: 'test@example.com', + role: 'user', + }; + + const mockTagLink = { + id: 'link-1', + tagId: 'tag-1', + appId: 'todo', + entityId: 'task-1', + entityType: 'task', + userId: 'test-user-id', + createdAt: new Date(), + }; + + const mockTag = { + id: 'tag-1', + userId: 'test-user-id', + name: 'Arbeit', + color: '#3B82F6', + icon: 'Briefcase', + groupId: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockTagLinksServiceValue = { + create: jest.fn(), + bulkCreate: jest.fn(), + delete: jest.fn(), + query: jest.fn(), + getTagsForEntity: jest.fn(), + sync: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TagLinksController], + providers: [ + { + provide: TagLinksService, + useValue: mockTagLinksServiceValue, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + controller = module.get(TagLinksController); + tagLinksService = module.get(TagLinksService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================ + // POST /tag-links + // ============================================================================ + + describe('POST /tag-links', () => { + it('should create a tag link and return it', async () => { + const createDto = { + tagId: 'tag-1', + appId: 'todo', + entityId: 'task-1', + entityType: 'task', + }; + + tagLinksService.create.mockResolvedValue(mockTagLink); + + const result = await controller.create(mockUser, createDto); + + expect(result).toEqual(mockTagLink); + expect(tagLinksService.create).toHaveBeenCalledWith('test-user-id', createDto); + }); + + it('should propagate NotFoundException when tag does not exist', async () => { + const createDto = { + tagId: 'nonexistent', + appId: 'todo', + entityId: 'task-1', + entityType: 'task', + }; + + tagLinksService.create.mockRejectedValue(new NotFoundException('Tag not found')); + + await expect(controller.create(mockUser, createDto)).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================================================ + // POST /tag-links/bulk + // ============================================================================ + + describe('POST /tag-links/bulk', () => { + it('should bulk create tag links and return them', async () => { + const links = [ + { tagId: 'tag-1', appId: 'todo', entityId: 'task-1', entityType: 'task' }, + { tagId: 'tag-2', appId: 'todo', entityId: 'task-1', entityType: 'task' }, + ]; + const createdLinks = [mockTagLink, { ...mockTagLink, id: 'link-2', tagId: 'tag-2' }]; + + tagLinksService.bulkCreate.mockResolvedValue(createdLinks); + + const result = await controller.bulkCreate(mockUser, { links }); + + expect(result).toEqual(createdLinks); + expect(tagLinksService.bulkCreate).toHaveBeenCalledWith('test-user-id', links); + }); + + it('should propagate NotFoundException when one or more tags not found', async () => { + const links = [ + { tagId: 'tag-1', appId: 'todo', entityId: 'task-1', entityType: 'task' }, + { tagId: 'nonexistent', appId: 'todo', entityId: 'task-1', entityType: 'task' }, + ]; + + tagLinksService.bulkCreate.mockRejectedValue( + new NotFoundException('One or more tags not found') + ); + + await expect(controller.bulkCreate(mockUser, { links })).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================================================ + // PUT /tag-links/sync + // ============================================================================ + + describe('PUT /tag-links/sync', () => { + it('should sync entity tags and return updated tag list', async () => { + const syncDto = { + appId: 'todo', + entityId: 'task-1', + entityType: 'task', + tagIds: ['tag-1', 'tag-3'], + }; + const updatedTags = [mockTag, { ...mockTag, id: 'tag-3', name: 'Familie' }]; + + tagLinksService.sync.mockResolvedValue(updatedTags); + + const result = await controller.sync(mockUser, syncDto); + + expect(result).toEqual(updatedTags); + expect(tagLinksService.sync).toHaveBeenCalledWith('test-user-id', 'todo', 'task-1', 'task', [ + 'tag-1', + 'tag-3', + ]); + }); + + it('should propagate NotFoundException when tags do not belong to user', async () => { + const syncDto = { + appId: 'todo', + entityId: 'task-1', + entityType: 'task', + tagIds: ['nonexistent'], + }; + + tagLinksService.sync.mockRejectedValue(new NotFoundException('One or more tags not found')); + + await expect(controller.sync(mockUser, syncDto)).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================================================ + // GET /tag-links/tags-for-entity + // ============================================================================ + + describe('GET /tag-links/tags-for-entity', () => { + it('should return full tag objects for an entity', async () => { + const entityTags = [mockTag]; + + tagLinksService.getTagsForEntity.mockResolvedValue(entityTags); + + const result = await controller.getTagsForEntity(mockUser, { + appId: 'todo', + entityId: 'task-1', + }); + + expect(result).toEqual(entityTags); + expect(tagLinksService.getTagsForEntity).toHaveBeenCalledWith( + 'test-user-id', + 'todo', + 'task-1' + ); + }); + + it('should return empty array when entity has no tags', async () => { + tagLinksService.getTagsForEntity.mockResolvedValue([]); + + const result = await controller.getTagsForEntity(mockUser, { + appId: 'todo', + entityId: 'task-99', + }); + + expect(result).toEqual([]); + }); + }); + + // ============================================================================ + // GET /tag-links + // ============================================================================ + + describe('GET /tag-links', () => { + it('should query tag links with filters', async () => { + const links = [mockTagLink]; + tagLinksService.query.mockResolvedValue(links); + + const queryDto = { appId: 'todo', entityType: 'task' }; + + const result = await controller.query(mockUser, queryDto); + + expect(result).toEqual(links); + expect(tagLinksService.query).toHaveBeenCalledWith('test-user-id', queryDto); + }); + + it('should return all links when no filters provided', async () => { + const links = [mockTagLink, { ...mockTagLink, id: 'link-2', appId: 'calendar' }]; + tagLinksService.query.mockResolvedValue(links); + + const result = await controller.query(mockUser, {}); + + expect(result).toEqual(links); + expect(tagLinksService.query).toHaveBeenCalledWith('test-user-id', {}); + }); + }); + + // ============================================================================ + // DELETE /tag-links/:id + // ============================================================================ + + describe('DELETE /tag-links/:id', () => { + it('should delete a tag link and return void', async () => { + tagLinksService.delete.mockResolvedValue(undefined); + + const result = await controller.delete(mockUser, 'link-1'); + + expect(result).toBeUndefined(); + expect(tagLinksService.delete).toHaveBeenCalledWith('link-1', 'test-user-id'); + }); + + it('should propagate NotFoundException when link does not exist', async () => { + tagLinksService.delete.mockRejectedValue(new NotFoundException('Tag link not found')); + + await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/services/mana-core-auth/src/tag-links/tag-links.service.ts b/services/mana-core-auth/src/tag-links/tag-links.service.ts index 50ac009cf..333c1ece3 100644 --- a/services/mana-core-auth/src/tag-links/tag-links.service.ts +++ b/services/mana-core-auth/src/tag-links/tag-links.service.ts @@ -169,7 +169,8 @@ export class TagLinksService { } /** - * Sync tags for an entity: adds missing links, removes extra ones + * Sync tags for an entity: adds missing links, removes extra ones. + * Wrapped in a transaction to prevent race conditions. */ async sync( userId: string, @@ -180,7 +181,7 @@ export class TagLinksService { ) { const db = this.getDb(); - // Verify all tags belong to user + // Verify all tags belong to user (before transaction) if (tagIds.length > 0) { const userTags = await db .select() @@ -192,43 +193,49 @@ export class TagLinksService { } } - // Get current links for this entity - const currentLinks = await db - .select() - .from(tagLinks) - .where( - and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId)) - ); + await db.transaction(async (tx) => { + // Get current links for this entity + const currentLinks = await tx + .select() + .from(tagLinks) + .where( + and( + eq(tagLinks.userId, userId), + eq(tagLinks.appId, appId), + eq(tagLinks.entityId, entityId) + ) + ); - const currentTagIds = currentLinks.map((l) => l.tagId); - const toAdd = tagIds.filter((id) => !currentTagIds.includes(id)); - const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId)); + const currentTagIds = currentLinks.map((l) => l.tagId); + const toAdd = tagIds.filter((id) => !currentTagIds.includes(id)); + const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId)); - // Add missing links - if (toAdd.length > 0) { - await db - .insert(tagLinks) - .values( - toAdd.map((tagId) => ({ - tagId, - appId, - entityId, - entityType, - userId, - })) - ) - .onConflictDoNothing(); - } + // Add missing links + if (toAdd.length > 0) { + await tx + .insert(tagLinks) + .values( + toAdd.map((tagId) => ({ + tagId, + appId, + entityId, + entityType, + userId, + })) + ) + .onConflictDoNothing(); + } - // Remove extra links - if (toRemove.length > 0) { - const removeIds = toRemove.map((l) => l.id); - await db - .delete(tagLinks) - .where(and(inArray(tagLinks.id, removeIds), eq(tagLinks.userId, userId))); - } + // Remove extra links + if (toRemove.length > 0) { + const removeIds = toRemove.map((l) => l.id); + await tx + .delete(tagLinks) + .where(and(inArray(tagLinks.id, removeIds), eq(tagLinks.userId, userId))); + } + }); - // Return updated tags for entity + // Return updated tags for entity (after transaction commits) return this.getTagsForEntity(userId, appId, entityId); } } diff --git a/services/mana-core-auth/src/tags/tags.controller.spec.ts b/services/mana-core-auth/src/tags/tags.controller.spec.ts new file mode 100644 index 000000000..c5205e011 --- /dev/null +++ b/services/mana-core-auth/src/tags/tags.controller.spec.ts @@ -0,0 +1,241 @@ +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { TagsController } from './tags.controller'; +import { TagsService } from './tags.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; + +describe('TagsController', () => { + let controller: TagsController; + let tagsService: jest.Mocked; + + const mockUser: CurrentUserData = { + userId: 'test-user-id', + email: 'test@example.com', + role: 'user', + }; + + const mockTag = { + id: 'tag-1', + userId: 'test-user-id', + name: 'Arbeit', + color: '#3B82F6', + icon: 'Briefcase', + groupId: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockTagsServiceValue = { + findByUserId: jest.fn(), + findById: jest.fn(), + getByIds: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + createDefaultTags: jest.fn(), + findByGroupId: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TagsController], + providers: [ + { + provide: TagsService, + useValue: mockTagsServiceValue, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + controller = module.get(TagsController); + tagsService = module.get(TagsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================ + // GET /tags + // ============================================================================ + + describe('GET /tags', () => { + it('should return all tags for the authenticated user', async () => { + const userTags = [ + mockTag, + { ...mockTag, id: 'tag-2', name: 'Persönlich', color: '#10B981', icon: 'User' }, + ]; + + tagsService.findByUserId.mockResolvedValue(userTags); + + const result = await controller.findAll(mockUser); + + expect(result).toEqual(userTags); + expect(tagsService.findByUserId).toHaveBeenCalledWith('test-user-id'); + }); + + it('should return empty array when user has no tags', async () => { + tagsService.findByUserId.mockResolvedValue([]); + + const result = await controller.findAll(mockUser); + + expect(result).toEqual([]); + }); + }); + + // ============================================================================ + // GET /tags/by-ids + // ============================================================================ + + describe('GET /tags/by-ids', () => { + it('should resolve tag IDs to full tag objects', async () => { + const resolvedTags = [mockTag]; + tagsService.getByIds.mockResolvedValue(resolvedTags); + + const result = await controller.getByIds(mockUser, 'tag-1,tag-2'); + + expect(result).toEqual(resolvedTags); + expect(tagsService.getByIds).toHaveBeenCalledWith(['tag-1', 'tag-2'], 'test-user-id'); + }); + + it('should return empty array when no ids provided', async () => { + const result = await controller.getByIds(mockUser, undefined); + + expect(result).toEqual([]); + expect(tagsService.getByIds).not.toHaveBeenCalled(); + }); + + it('should return empty array when ids is empty string', async () => { + const result = await controller.getByIds(mockUser, ''); + + expect(result).toEqual([]); + expect(tagsService.getByIds).not.toHaveBeenCalled(); + }); + }); + + // ============================================================================ + // GET /tags/:id + // ============================================================================ + + describe('GET /tags/:id', () => { + it('should return a single tag by ID', async () => { + tagsService.findById.mockResolvedValue(mockTag); + + const result = await controller.findOne(mockUser, 'tag-1'); + + expect(result).toEqual(mockTag); + expect(tagsService.findById).toHaveBeenCalledWith('tag-1', 'test-user-id'); + }); + + it('should return null when tag not found', async () => { + tagsService.findById.mockResolvedValue(null as any); + + const result = await controller.findOne(mockUser, 'nonexistent'); + + expect(result).toBeNull(); + }); + }); + + // ============================================================================ + // POST /tags + // ============================================================================ + + describe('POST /tags', () => { + it('should create a new tag and return it', async () => { + const createDto = { name: 'Neuer Tag', color: '#FF5733', icon: 'Star' }; + const createdTag = { ...mockTag, ...createDto, id: 'tag-new' }; + + tagsService.create.mockResolvedValue(createdTag); + + const result = await controller.create(mockUser, createDto); + + expect(result).toEqual(createdTag); + expect(tagsService.create).toHaveBeenCalledWith('test-user-id', createDto); + }); + + it('should propagate ConflictException for duplicate tag name', async () => { + const createDto = { name: 'Arbeit' }; + + tagsService.create.mockRejectedValue(new ConflictException('Tag "Arbeit" already exists')); + + await expect(controller.create(mockUser, createDto)).rejects.toThrow(ConflictException); + }); + }); + + // ============================================================================ + // POST /tags/defaults + // ============================================================================ + + describe('POST /tags/defaults', () => { + it('should create default tags for the user', async () => { + const defaultTags = [ + { ...mockTag, name: 'Arbeit' }, + { ...mockTag, id: 'tag-2', name: 'Persönlich' }, + { ...mockTag, id: 'tag-3', name: 'Familie' }, + { ...mockTag, id: 'tag-4', name: 'Wichtig' }, + ]; + + tagsService.createDefaultTags.mockResolvedValue(defaultTags); + + const result = await controller.createDefaults(mockUser); + + expect(result).toEqual(defaultTags); + expect(tagsService.createDefaultTags).toHaveBeenCalledWith('test-user-id'); + }); + }); + + // ============================================================================ + // PUT /tags/:id + // ============================================================================ + + describe('PUT /tags/:id', () => { + it('should update a tag and return the updated version', async () => { + const updateDto = { name: 'Aktualisiert', color: '#000000' }; + const updatedTag = { ...mockTag, ...updateDto }; + + tagsService.update.mockResolvedValue(updatedTag); + + const result = await controller.update(mockUser, 'tag-1', updateDto); + + expect(result).toEqual(updatedTag); + expect(tagsService.update).toHaveBeenCalledWith('tag-1', 'test-user-id', updateDto); + }); + + it('should propagate NotFoundException when tag does not exist', async () => { + const updateDto = { name: 'Updated' }; + + tagsService.update.mockRejectedValue(new NotFoundException('Tag not found')); + + await expect(controller.update(mockUser, 'nonexistent', updateDto)).rejects.toThrow( + NotFoundException + ); + }); + }); + + // ============================================================================ + // DELETE /tags/:id + // ============================================================================ + + describe('DELETE /tags/:id', () => { + it('should delete a tag and return void', async () => { + tagsService.delete.mockResolvedValue(undefined); + + const result = await controller.delete(mockUser, 'tag-1'); + + expect(result).toBeUndefined(); + expect(tagsService.delete).toHaveBeenCalledWith('tag-1', 'test-user-id'); + }); + + it('should propagate NotFoundException when tag does not exist', async () => { + tagsService.delete.mockRejectedValue(new NotFoundException('Tag not found')); + + await expect(controller.delete(mockUser, 'nonexistent')).rejects.toThrow(NotFoundException); + }); + }); +});