mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +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;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: 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 {
|
.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(
|
async sync(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|
@ -180,7 +181,7 @@ export class TagLinksService {
|
||||||
) {
|
) {
|
||||||
const db = this.getDb();
|
const db = this.getDb();
|
||||||
|
|
||||||
// Verify all tags belong to user
|
// Verify all tags belong to user (before transaction)
|
||||||
if (tagIds.length > 0) {
|
if (tagIds.length > 0) {
|
||||||
const userTags = await db
|
const userTags = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -192,43 +193,49 @@ export class TagLinksService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current links for this entity
|
await db.transaction(async (tx) => {
|
||||||
const currentLinks = await db
|
// Get current links for this entity
|
||||||
.select()
|
const currentLinks = await tx
|
||||||
.from(tagLinks)
|
.select()
|
||||||
.where(
|
.from(tagLinks)
|
||||||
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
|
.where(
|
||||||
);
|
and(
|
||||||
|
eq(tagLinks.userId, userId),
|
||||||
|
eq(tagLinks.appId, appId),
|
||||||
|
eq(tagLinks.entityId, entityId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const currentTagIds = currentLinks.map((l) => l.tagId);
|
const currentTagIds = currentLinks.map((l) => l.tagId);
|
||||||
const toAdd = tagIds.filter((id) => !currentTagIds.includes(id));
|
const toAdd = tagIds.filter((id) => !currentTagIds.includes(id));
|
||||||
const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId));
|
const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId));
|
||||||
|
|
||||||
// Add missing links
|
// Add missing links
|
||||||
if (toAdd.length > 0) {
|
if (toAdd.length > 0) {
|
||||||
await db
|
await tx
|
||||||
.insert(tagLinks)
|
.insert(tagLinks)
|
||||||
.values(
|
.values(
|
||||||
toAdd.map((tagId) => ({
|
toAdd.map((tagId) => ({
|
||||||
tagId,
|
tagId,
|
||||||
appId,
|
appId,
|
||||||
entityId,
|
entityId,
|
||||||
entityType,
|
entityType,
|
||||||
userId,
|
userId,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove extra links
|
// Remove extra links
|
||||||
if (toRemove.length > 0) {
|
if (toRemove.length > 0) {
|
||||||
const removeIds = toRemove.map((l) => l.id);
|
const removeIds = toRemove.map((l) => l.id);
|
||||||
await db
|
await tx
|
||||||
.delete(tagLinks)
|
.delete(tagLinks)
|
||||||
.where(and(inArray(tagLinks.id, removeIds), eq(tagLinks.userId, userId)));
|
.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);
|
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