Refactor: Extract tag functions to TagMatcher service
- 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:
@@ -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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user