mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
fix(tags): transaction on sync, scroll indicator, backend tests (37 tests)
- Wrap TagLinksService.sync() in db.transaction() to prevent race conditions - Add CSS mask-image fade edges on TagStrip for scroll affordance - Add 37 unit tests for tag controllers: - TagsController: 12 tests (CRUD, defaults, conflict, not-found) - TagGroupsController: 10 tests (CRUD, reorder, cascading) - TagLinksController: 15 tests (link/unlink, bulk, sync, query) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0dfd603892
commit
4ddff8485b
5 changed files with 768 additions and 35 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<TagGroupsService>;
|
||||
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TagLinksService>;
|
||||
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
241
services/mana-core-auth/src/tags/tags.controller.spec.ts
Normal file
241
services/mana-core-auth/src/tags/tags.controller.spec.ts
Normal file
|
|
@ -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<TagsService>;
|
||||
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue