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:
Till JS 2026-03-26 22:00:12 +01:00
parent 0dfd603892
commit 4ddff8485b
5 changed files with 768 additions and 35 deletions

View file

@ -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 {

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
} }
} }

View 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);
});
});
});