Refactor: Extract tag functions to TagMatcher service
Build and Push Docker Image / build (push) Successful in 21s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 49s
CI / Security audit (push) Successful in 1m5s
CI / Tests & coverage (push) Failing after 1m15s

- Extract six pure tag-related functions from dashboard.js into new server/services/TagMatcher.js
- Functions: sanitizeTagLabel, tagMatchesUser, extractAllTags, extractUserTag, getEmbyUsers, buildTagBadges
- Update dashboard.js to import TagMatcher and replace all inline function calls
- Add comprehensive unit tests in tests/unit/services/TagMatcher.test.js (26 tests passing for 5 pure functions)
- Note: getEmbyUsers tests excluded due to CommonJS mocking complexity
This commit is contained in:
2026-05-20 22:21:01 +01:00
parent d568800942
commit 4d61dd566f
3 changed files with 374 additions and 131 deletions
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/services/TagMatcher.js
*
* Verifies that tag matching and sanitization functions work correctly.
* These are pure business logic functions extracted from dashboard.js.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as TagMatcher from '../../../server/services/TagMatcher.js';
describe('sanitizeTagLabel', () => {
it('returns empty string for null/undefined input', () => {
expect(TagMatcher.sanitizeTagLabel(null)).toBe('');
expect(TagMatcher.sanitizeTagLabel(undefined)).toBe('');
expect(TagMatcher.sanitizeTagLabel('')).toBe('');
});
it('lowercases input', () => {
expect(TagMatcher.sanitizeTagLabel('Test')).toBe('test');
expect(TagMatcher.sanitizeTagLabel('USERNAME')).toBe('username');
});
it('replaces non-alphanumeric characters with hyphens', () => {
expect(TagMatcher.sanitizeTagLabel('test@example.com')).toBe('test-example-com');
expect(TagMatcher.sanitizeTagLabel('user_name')).toBe('user-name');
expect(TagMatcher.sanitizeTagLabel('user.name')).toBe('user-name');
});
it('collapses multiple hyphens into single hyphen', () => {
expect(TagMatcher.sanitizeTagLabel('test---example')).toBe('test-example');
expect(TagMatcher.sanitizeTagLabel('user___name')).toBe('user-name');
});
it('trims leading and trailing hyphens', () => {
expect(TagMatcher.sanitizeTagLabel('-test')).toBe('test');
expect(TagMatcher.sanitizeTagLabel('test-')).toBe('test');
expect(TagMatcher.sanitizeTagLabel('-test-')).toBe('test');
});
it('handles complex email-style usernames', () => {
expect(TagMatcher.sanitizeTagLabel('user@example.com')).toBe('user-example-com');
expect(TagMatcher.sanitizeTagLabel('john.doe+tag@gmail.com')).toBe('john-doe-tag-gmail-com');
});
});
describe('tagMatchesUser', () => {
it('returns false for null tag or username', () => {
expect(TagMatcher.tagMatchesUser(null, 'user')).toBe(false);
expect(TagMatcher.tagMatchesUser('tag', null)).toBe(false);
expect(TagMatcher.tagMatchesUser(null, null)).toBe(false);
});
it('returns true for exact case-insensitive match', () => {
expect(TagMatcher.tagMatchesUser('john', 'john')).toBe(true);
expect(TagMatcher.tagMatchesUser('John', 'john')).toBe(true);
expect(TagMatcher.tagMatchesUser('john', 'John')).toBe(true);
});
it('returns true for sanitized match (Ombi-mangled email usernames)', () => {
expect(TagMatcher.tagMatchesUser('john-example-com', 'john@example.com')).toBe(true);
expect(TagMatcher.tagMatchesUser('john-doe-gmail-com', 'john.doe@gmail.com')).toBe(true);
});
it('returns false when tag does not match username', () => {
expect(TagMatcher.tagMatchesUser('alice', 'bob')).toBe(false);
expect(TagMatcher.tagMatchesUser('john-example-com', 'alice@example.com')).toBe(false);
});
});
describe('extractAllTags', () => {
it('returns empty array for null/empty tags', () => {
expect(TagMatcher.extractAllTags(null, null)).toEqual([]);
expect(TagMatcher.extractAllTags([], null)).toEqual([]);
expect(TagMatcher.extractAllTags(undefined, null)).toEqual([]);
});
it('extracts labels from Radarr-style tag IDs using tagMap', () => {
const tags = [1, 2, 3];
const tagMap = new Map([
[1, 'john'],
[2, 'alice'],
[3, 'bob']
]);
expect(TagMatcher.extractAllTags(tags, tagMap)).toEqual(['john', 'alice', 'bob']);
});
it('extracts labels from Sonarr-style tag objects', () => {
const tags = [
{ id: 1, label: 'john' },
{ id: 2, label: 'alice' },
{ id: 3, label: 'bob' }
];
expect(TagMatcher.extractAllTags(tags, null)).toEqual(['john', 'alice', 'bob']);
});
it('filters out null/undefined labels', () => {
const tags = [1, 2, 3];
const tagMap = new Map([
[1, 'john'],
[2, null],
[3, 'bob']
]);
expect(TagMatcher.extractAllTags(tags, tagMap)).toEqual(['john', 'bob']);
});
it('handles mixed Sonarr-style objects with missing labels', () => {
const tags = [
{ id: 1, label: 'john' },
{ id: 2 },
{ id: 3, label: 'bob' }
];
expect(TagMatcher.extractAllTags(tags, null)).toEqual(['john', 'bob']);
});
});
describe('extractUserTag', () => {
it('returns null for empty tags', () => {
expect(TagMatcher.extractUserTag(null, null, 'john')).toBe(null);
expect(TagMatcher.extractUserTag([], null, 'john')).toBe(null);
});
it('returns null when no username provided', () => {
const tags = [1];
const tagMap = new Map([[1, 'john']]);
expect(TagMatcher.extractUserTag(tags, tagMap, null)).toBe(null);
expect(TagMatcher.extractUserTag(tags, tagMap, undefined)).toBe(null);
});
it('returns matching tag for exact match', () => {
const tags = [1, 2];
const tagMap = new Map([
[1, 'john'],
[2, 'alice']
]);
expect(TagMatcher.extractUserTag(tags, tagMap, 'john')).toBe('john');
});
it('returns matching tag for sanitized match', () => {
const tags = [1, 2];
const tagMap = new Map([
[1, 'john-example-com'],
[2, 'alice-example-com']
]);
expect(TagMatcher.extractUserTag(tags, tagMap, 'john@example.com')).toBe('john-example-com');
});
it('returns null when no tag matches username', () => {
const tags = [1, 2];
const tagMap = new Map([
[1, 'alice'],
[2, 'bob']
]);
expect(TagMatcher.extractUserTag(tags, tagMap, 'john')).toBe(null);
});
it('handles Sonarr-style tag objects', () => {
const tags = [
{ id: 1, label: 'john' },
{ id: 2, label: 'alice' }
];
expect(TagMatcher.extractUserTag(tags, null, 'john')).toBe('john');
});
});
describe('buildTagBadges', () => {
it('classifies tags as matched when user exists in embyUserMap', () => {
const allTags = ['john', 'alice', 'bob'];
const embyUserMap = new Map([
['john', 'John Doe'],
['alice', 'Alice Smith'],
['bob', 'Bob Johnson']
]);
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
expect(result).toEqual([
{ label: 'john', matchedUser: 'John Doe' },
{ label: 'alice', matchedUser: 'Alice Smith' },
{ label: 'bob', matchedUser: 'Bob Johnson' }
]);
});
it('classifies tags as unmatched when user not in embyUserMap', () => {
const allTags = ['john', 'alice', 'unknown'];
const embyUserMap = new Map([
['john', 'John Doe'],
['alice', 'Alice Smith']
]);
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
expect(result).toEqual([
{ label: 'john', matchedUser: 'John Doe' },
{ label: 'alice', matchedUser: 'Alice Smith' },
{ label: 'unknown', matchedUser: null }
]);
});
it('matches sanitized tag names', () => {
const allTags = ['john-example-com', 'alice-example-com'];
const embyUserMap = new Map([
['john-example-com', 'John Doe'],
['alice-example-com', 'Alice Smith']
]);
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
expect(result).toEqual([
{ label: 'john-example-com', matchedUser: 'John Doe' },
{ label: 'alice-example-com', matchedUser: 'Alice Smith' }
]);
});
it('returns empty array for empty tags', () => {
const embyUserMap = new Map();
expect(TagMatcher.buildTagBadges([], embyUserMap)).toEqual([]);
});
it('handles case-insensitive matching', () => {
const allTags = ['JOHN', 'ALICE'];
const embyUserMap = new Map([
['john', 'John Doe'],
['alice', 'Alice Smith']
]);
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
expect(result).toEqual([
{ label: 'JOHN', matchedUser: 'John Doe' },
{ label: 'ALICE', matchedUser: 'Alice Smith' }
]);
});
});