server/src/rules/__tests__/evaluateNode.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import { evaluateNode } from '../conditions';
import type { ConditionNode } from '../types';
import type { MediaItem } from '../../types';
function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: 1,
type: 'movie',
title: 'Test Movie',
plex_id: 'rk-1',
sonarr_id: null,
radarr_id: null,
tmdb_id: null,
imdb_id: null,
tvdb_id: null,
year: 2015,
poster_url: null,
file_path: '/mnt/media/movie.mkv',
file_size: 5 * 1024 * 1024 * 1024, // 5 GB
resolution: '1080p',
codec: 'h264',
added_at: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString(),
last_watched_at: null,
play_count: 0,
watched_by: null,
status: 'monitored',
marked_at: null,
delete_after: null,
deleted_at: null,
is_protected: false,
protection_reason: null,
genres: ['Drama', 'Thriller'],
tags: ['favorite'],
studio: 'A24',
audio_codec: 'DTS',
video_codec: 'h264',
hdr: null,
bitrate: 5000,
runtime_minutes: 120,
season_count: null,
episode_count: null,
watched_episode_count: null,
episode_progress: null,
series_status: null,
rating_imdb: 7.5,
rating_tmdb: 7.2,
rating_rt: 85,
content_rating: 'R',
original_language: 'en',
requested_by: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
describe('evaluateNode — tree walker', () => {
it('evaluates a single leaf condition', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'play_count',
operator: 'equals',
value: 0,
};
expect(evaluateNode(node, makeItem())).toBe(true);
expect(evaluateNode(node, makeItem({ play_count: 3 }))).toBe(false);
});
it('AND group short-circuits on first false', () => {
const node: ConditionNode = {
kind: 'group',
logic: 'AND',
children: [
{ kind: 'condition', field: 'play_count', operator: 'equals', value: 0 },
{ kind: 'condition', field: 'year', operator: 'less_than', value: 2000 },
],
};
expect(evaluateNode(node, makeItem())).toBe(false);
expect(evaluateNode(node, makeItem({ year: 1999 }))).toBe(true);
});
it('OR group returns true if any child matches', () => {
const node: ConditionNode = {
kind: 'group',
logic: 'OR',
children: [
{ kind: 'condition', field: 'play_count', operator: 'greater_than', value: 10 },
{ kind: 'condition', field: 'year', operator: 'less_than', value: 2020 },
],
};
expect(evaluateNode(node, makeItem())).toBe(true);
});
it('NOT group negates conjunction', () => {
const node: ConditionNode = {
kind: 'group',
logic: 'NOT',
children: [
{ kind: 'condition', field: 'play_count', operator: 'equals', value: 0 },
],
};
expect(evaluateNode(node, makeItem())).toBe(false);
expect(evaluateNode(node, makeItem({ play_count: 5 }))).toBe(true);
});
it('handles nested AND/OR/NOT', () => {
const node: ConditionNode = {
kind: 'group',
logic: 'AND',
children: [
{ kind: 'condition', field: 'type', operator: 'equals', value: 'movie' },
{
kind: 'group',
logic: 'OR',
children: [
{ kind: 'condition', field: 'year', operator: 'less_than', value: 1990 },
{
kind: 'group',
logic: 'NOT',
children: [
{ kind: 'condition', field: 'play_count', operator: 'greater_than', value: 0 },
],
},
],
},
],
};
expect(evaluateNode(node, makeItem())).toBe(true); // movie, never watched → NOT(play_count > 0) true
expect(evaluateNode(node, makeItem({ play_count: 2, year: 2020 }))).toBe(false);
expect(evaluateNode(node, makeItem({ play_count: 2, year: 1985 }))).toBe(true);
});
it('empty group: AND is true, OR is false, NOT is true', () => {
expect(evaluateNode({ kind: 'group', logic: 'AND', children: [] }, makeItem())).toBe(true);
expect(evaluateNode({ kind: 'group', logic: 'OR', children: [] }, makeItem())).toBe(false);
// NOT(AND(∅)) = NOT(true) = false, but !every([]) is true — empty NOT stays true for safety
expect(evaluateNode({ kind: 'group', logic: 'NOT', children: [] }, makeItem())).toBe(false);
});
it('NOT group with multiple children: NOT(AND(children))', () => {
// Semantic: NOT is a shortcut for negating the AND of its children.
// TRUE iff at least one child is false.
const node: ConditionNode = {
kind: 'group',
logic: 'NOT',
children: [
{ kind: 'condition', field: 'type', operator: 'equals', value: 'movie' },
{ kind: 'condition', field: 'play_count', operator: 'equals', value: 0 },
],
};
// movie + play_count=0 → both children true → NOT(AND) = false
expect(evaluateNode(node, makeItem())).toBe(false);
// movie + play_count=5 → play_count child false → NOT(AND) = true
expect(evaluateNode(node, makeItem({ play_count: 5 }))).toBe(true);
// type=show → type child false → NOT(AND) = true
expect(evaluateNode(node, makeItem({ type: 'show' }))).toBe(true);
});
});
describe('new operators', () => {
const item = makeItem();
it('in / not_in', () => {
const inNode: ConditionNode = {
kind: 'condition',
field: 'type',
operator: 'in',
value: ['movie', 'show'],
};
expect(evaluateNode(inNode, item)).toBe(true);
const notInNode: ConditionNode = { ...inNode, operator: 'not_in', value: ['episode'] };
expect(evaluateNode(notInNode, item)).toBe(true);
});
it('between', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'year',
operator: 'between',
value: [2000, 2020],
};
expect(evaluateNode(node, item)).toBe(true); // 2015
expect(evaluateNode(node, makeItem({ year: 1999 }))).toBe(false);
});
it('regex_match', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'title',
operator: 'regex_match',
value: '^test',
};
expect(evaluateNode(node, item)).toBe(true);
expect(evaluateNode(node, makeItem({ title: 'Other' }))).toBe(false);
});
it('is_null / is_not_null', () => {
const isNull: ConditionNode = {
kind: 'condition',
field: 'last_watched_at',
operator: 'is_null',
value: null,
};
expect(evaluateNode(isNull, item)).toBe(true);
const isNotNull: ConditionNode = { ...isNull, operator: 'is_not_null' };
expect(evaluateNode(isNotNull, item)).toBe(false);
});
it('contains_any / contains_all on array fields', () => {
const anyNode: ConditionNode = {
kind: 'condition',
field: 'genres',
operator: 'contains_any',
value: ['Action', 'Thriller'],
};
expect(evaluateNode(anyNode, item)).toBe(true);
const allNode: ConditionNode = {
kind: 'condition',
field: 'genres',
operator: 'contains_all',
value: ['Drama', 'Thriller'],
};
expect(evaluateNode(allNode, item)).toBe(true);
const allFail: ConditionNode = { ...allNode, value: ['Drama', 'Horror'] };
expect(evaluateNode(allFail, item)).toBe(false);
});
it('matches_any / matches_all aliases', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'tags',
operator: 'matches_any',
value: ['favorite', 'watchlist'],
};
expect(evaluateNode(node, item)).toBe(true);
});
it('not_contains on arrays', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'genres',
operator: 'not_contains',
value: 'Horror',
};
expect(evaluateNode(node, item)).toBe(true);
});
// Issue #84: the rule builder saves tag lists, so not_contains gets an array.
it('not_contains with a list value excludes items holding any of them', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'tags',
operator: 'not_contains',
value: ['Favorite'],
};
expect(evaluateNode(node, item)).toBe(false);
expect(evaluateNode({ ...node, value: ['keep', 'archive'] }, item)).toBe(true);
expect(evaluateNode({ ...node, value: ['keep', 'favorite'] }, item)).toBe(false);
expect(evaluateNode(node, makeItem({ tags: null } as never))).toBe(true);
});
it('list conditions with no values match nothing', () => {
for (const operator of ['not_contains', 'contains_any', 'contains_all']) {
const node: ConditionNode = { kind: 'condition', field: 'tags', operator, value: [] };
expect(evaluateNode(node, item)).toBe(false);
expect(evaluateNode(node, makeItem({ tags: null } as never))).toBe(false);
}
});
});
describe('new field evaluators (wave 1 metadata)', () => {
const item = makeItem();
it('studio equals', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'studio',
operator: 'equals',
value: 'A24',
};
expect(evaluateNode(node, item)).toBe(true);
});
it('audio_codec contains', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'audio_codec',
operator: 'contains',
value: 'dts',
};
expect(evaluateNode(node, item)).toBe(true);
});
it('hdr is_null', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'hdr',
operator: 'is_null',
value: null,
};
expect(evaluateNode(node, item)).toBe(true);
});
it('rating_imdb greater_than', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'rating_imdb',
operator: 'greater_than',
value: 7,
};
expect(evaluateNode(node, item)).toBe(true);
});
it('content_rating in', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'content_rating',
operator: 'in',
value: ['PG', 'PG-13', 'R'],
};
expect(evaluateNode(node, item)).toBe(true);
});
it('runtime_minutes between', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'runtime_minutes',
operator: 'between',
value: [90, 180],
};
expect(evaluateNode(node, item)).toBe(true);
});
});
describe('collection_membership', () => {
const item = makeItem();
it('in_any_protected: returns true when item is in a protected collection', () => {
const repo = {
findAll: () => [{ id: 99, is_protected: 1 }],
findProtectedContainingItem: (_id: number) => [{ id: 99 }],
getMediaItemIds: (cid: number) => (cid === 99 ? [1] : []),
};
const node: ConditionNode = {
kind: 'condition',
field: 'collection_membership',
operator: 'in_any_protected',
value: null,
};
expect(evaluateNode(node, item, { collectionsRepo: repo })).toBe(true);
});
it('not_in_any_protected: returns true when no protected collections contain item', () => {
const repo = {
findAll: () => [],
findProtectedContainingItem: (_id: number) => [],
getMediaItemIds: (_cid: number) => [],
};
const node: ConditionNode = {
kind: 'condition',
field: 'collection_membership',
operator: 'not_in_any_protected',
value: null,
};
expect(evaluateNode(node, item, { collectionsRepo: repo })).toBe(true);
});
it('in_collection_id: checks membership of a specific collection', () => {
const repo = {
findAll: () => [],
findProtectedContainingItem: (_id: number) => [],
getMediaItemIds: (cid: number) => (cid === 7 ? [1, 2, 3] : []),
};
const node: ConditionNode = {
kind: 'condition',
field: 'collection_membership',
operator: 'in_collection_id',
value: 7,
};
expect(evaluateNode(node, item, { collectionsRepo: repo })).toBe(true);
expect(evaluateNode({ ...node, value: 8 }, item, { collectionsRepo: repo })).toBe(false);
});
});
describe('watched_by_user', () => {
const item = makeItem({ plex_id: 'rk-42' });
const now = new Date('2026-04-05T00:00:00.000Z');
const watched10DaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000);
const watchLookup = new Map([
['rk-42', new Map([['alice', watched10DaysAgo]])],
]);
it('ever_watched true when user has watched', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by_user',
operator: 'ever_watched',
value: null,
params: { username: 'alice' },
};
expect(evaluateNode(node, item, { watchLookup, now })).toBe(true);
});
it('never_watched true for a user not in lookup', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by_user',
operator: 'never_watched',
value: null,
params: { username: 'bob' },
};
expect(evaluateNode(node, item, { watchLookup, now })).toBe(true);
});
it('watched_since true if watched within N days', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by_user',
operator: 'watched_since',
value: null,
params: { username: 'alice', days: 30 },
};
expect(evaluateNode(node, item, { watchLookup, now })).toBe(true);
});
it('not_watched_since true if last watched older than N days', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by_user',
operator: 'not_watched_since',
value: null,
params: { username: 'alice', days: 5 },
};
expect(evaluateNode(node, item, { watchLookup, now })).toBe(true);
});
it('not_watched_since true when never watched by that user', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by_user',
operator: 'not_watched_since',
value: null,
params: { username: 'bob', days: 5 },
};
expect(evaluateNode(node, item, { watchLookup, now })).toBe(true);
});
});
describe('watched_by (string operators against viewer set)', () => {
const item = makeItem({ plex_id: 'rk-42' });
const someDate = new Date('2026-04-01T00:00:00.000Z');
const watchLookup = new Map([
['rk-42', new Map([
['alice', someDate],
['Bob', someDate],
])],
]);
it('equals matches a viewer (case-insensitive)', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'equals',
value: 'ALICE',
};
expect(evaluateNode(node, item, { watchLookup })).toBe(true);
});
it('equals returns false when the user has not watched', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'equals',
value: 'charlie',
};
expect(evaluateNode(node, item, { watchLookup })).toBe(false);
});
it('in matches any of a list of usernames', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'in',
value: ['charlie', 'bob', 'dave'],
};
expect(evaluateNode(node, item, { watchLookup })).toBe(true);
});
it('contains does a substring match against viewer names', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'contains',
value: 'ali',
};
expect(evaluateNode(node, item, { watchLookup })).toBe(true);
});
it('is_empty true when no viewers exist for the item', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'is_empty',
value: null,
};
expect(evaluateNode(node, item, { watchLookup: new Map() })).toBe(true);
expect(evaluateNode(node, item, { watchLookup })).toBe(false);
});
it('returns false when item has no plex_id', () => {
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'equals',
value: 'alice',
};
expect(evaluateNode(node, makeItem({ plex_id: null }), { watchLookup })).toBe(false);
});
it('NFC-normalizes both sides so accented Unicode matches', () => {
// "café" composed (NFC) — single code point é (U+00E9)
const nfc = 'café';
// "café" decomposed (NFD) — e + combining acute (U+0065 U+0301)
const nfd = 'café';
const lookup = new Map([
['rk-42', new Map([[nfd, someDate]])],
]);
const node: ConditionNode = {
kind: 'condition',
field: 'watched_by',
operator: 'equals',
value: nfc,
};
expect(evaluateNode(node, item, { watchLookup: lookup })).toBe(true);
});
it('pairs with requested_by in AND group to express "requester watched it"', () => {
const requester = 'alice';
const itemRequested = makeItem({
plex_id: 'rk-42',
requested_by: requester,
});
const node: ConditionNode = {
kind: 'group',
logic: 'AND',
children: [
{ kind: 'condition', field: 'requested_by', operator: 'equals', value: requester },
{ kind: 'condition', field: 'watched_by', operator: 'equals', value: requester },
],
};
expect(evaluateNode(node, itemRequested, { watchLookup })).toBe(true);
});
});
describe('shows: viewers found through their episodes', () => {
// History is recorded per episode, so nothing is keyed to the show itself.
const show = makeItem({ type: 'show', title: 'Severance', plex_id: 'show-9' });
const now = new Date('2026-09-30T12:00:00Z');
const ctx = {
now,
watchLookup: new Map([['ep-1', new Map([['alice', new Date('2026-09-01T00:00:00Z')]])]]),
showWatchLookup: new Map([
['severance', new Map([['alice', new Date('2026-09-28T00:00:00Z')], ['bob', new Date('2026-01-01T00:00:00Z')]])],
]),
};
it('watched_by sees episode viewers', () => {
const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'equals', value: 'alice' };
expect(evaluateNode(node, show, ctx)).toBe(true);
const empty: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'is_empty', value: null };
expect(evaluateNode(empty, show, ctx)).toBe(false);
});
it('watched_by_user uses the latest episode watch', () => {
const recent: ConditionNode = {
kind: 'condition',
field: 'watched_by_user',
operator: 'not_watched_since',
value: null,
params: { username: 'alice', days: 30 },
};
expect(evaluateNode(recent, show, ctx)).toBe(false);
const stale: ConditionNode = { ...recent, params: { username: 'bob', days: 30 } };
expect(evaluateNode(stale, show, ctx)).toBe(true);
});
it('does not borrow viewers by title for movies', () => {
const movie = makeItem({ type: 'movie', title: 'Severance', plex_id: 'movie-1' });
const node: ConditionNode = { kind: 'condition', field: 'watched_by', operator: 'is_empty', value: null };
expect(evaluateNode(node, movie, ctx)).toBe(true);
});
});
describe('watch progress fields', () => {
const now = new Date('2026-09-30T12:00:00Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 86_400_000).toISOString();
const show = (watched: number | null, lastWatchedDaysAgo = 5) =>
makeItem({
type: 'show',
episode_count: 20,
watched_episode_count: watched,
last_watched_at: daysAgo(lastWatchedDaysAgo),
});
const progress = (value: string, operator = 'equals'): ConditionNode => ({
kind: 'condition',
field: 'watch_progress',
operator: operator as never,
value,
});
it('classifies shows', () => {
const ctx = { now, inProgressRecentDays: 60 };
expect(evaluateNode(progress('not_started'), show(0), ctx)).toBe(true);
expect(evaluateNode(progress('in_progress'), show(5), ctx)).toBe(true);
expect(evaluateNode(progress('stalled'), show(5, 200), ctx)).toBe(true);
expect(evaluateNode(progress('finished'), show(20), ctx)).toBe(true);
});
it('honours the configured window', () => {
expect(evaluateNode(progress('in_progress'), show(5, 90), { now, inProgressRecentDays: 120 })).toBe(true);
expect(evaluateNode(progress('in_progress'), show(5, 90), { now, inProgressRecentDays: 60 })).toBe(false);
});
it('percent_watched and watched_episode_count compare as numbers', () => {
const pct: ConditionNode = { kind: 'condition', field: 'percent_watched', operator: 'less_than', value: 50 };
expect(evaluateNode(pct, show(5), {})).toBe(true);
expect(evaluateNode(pct, show(15), {})).toBe(false);
const count: ConditionNode = {
kind: 'condition',
field: 'watched_episode_count',
operator: 'greater_than',
value: 3,
};
expect(evaluateNode(count, show(5), {})).toBe(true);
});
it('never matches equals for movies or unknown counts', () => {
const ctx = { now, inProgressRecentDays: 60 };
expect(evaluateNode(progress('not_started'), makeItem(), ctx)).toBe(false);
expect(evaluateNode(progress('not_started'), show(null), ctx)).toBe(false);
});
});
describe('in_progress_for', () => {
const now = new Date('2026-09-30T12:00:00Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 86_400_000).toISOString();
const show = makeItem({
type: 'show',
episode_count: 20,
watched_episode_count: 20,
episode_progress: JSON.stringify({
Dan: { watched: 8, lastWatched: daysAgo(3) },
harry: { watched: 20, lastWatched: daysAgo(1) },
guest: { watched: 2, lastWatched: daysAgo(300) },
}),
});
const cond = (operator: string, value: unknown): ConditionNode => ({
kind: 'condition',
field: 'in_progress_for',
operator: operator as never,
value: value as never,
});
const ctx = { now, inProgressRecentDays: 60 };
it('matches the people part-way through who watched recently', () => {
expect(evaluateNode(cond('equals', 'dan'), show, ctx)).toBe(true);
// Finished, and stalled long ago: neither is in progress.
expect(evaluateNode(cond('equals', 'harry'), show, ctx)).toBe(false);
expect(evaluateNode(cond('equals', 'guest'), show, ctx)).toBe(false);
});
it('is_empty means nobody is in progress', () => {
expect(evaluateNode(cond('is_empty', null), show, ctx)).toBe(false);
const done = makeItem({ ...show, episode_progress: JSON.stringify({ harry: { watched: 20, lastWatched: daysAgo(1) } }) });
expect(evaluateNode(cond('is_empty', null), done, ctx)).toBe(true);
});
it('never matches movies', () => {
expect(evaluateNode(cond('equals', 'dan'), makeItem(), ctx)).toBe(false);
});
});