fix(channels): account for group_override in the auto-sync range-conflict check

The range-conflict warning classified each channel in the configured range as either this config's own auto-sync output or a real conflict, comparing each occupant against the source group. When a group sets a group_override, auto-sync creates its channels in the override target group, so the config's own channels failed that comparison and were misclassified as a conflict.

- Add effectiveSyncGroupId to resolve the group the sync's channels actually land in (the group_override target when set, otherwise the source group).
- Compare occupants against that effective target, so the config's own output is recognized while genuine conflicts (manual channels, channels from another account, channels in a different group, user-pinned numbers) still warn.
- Add Vitest coverage for the helper, the override case, and an over-suppression guard, plus a backend test asserting the numbers-in-range endpoint reports the override target group.
This commit is contained in:
None 2026-06-19 23:40:10 -05:00
parent 7139c88c35
commit a29704af55
4 changed files with 161 additions and 3 deletions

View file

@ -644,6 +644,34 @@ class NumbersInRangeLookupTests(TestCase):
)
self.assertFalse(occupant["has_channel_number_override"])
def test_group_override_channel_reports_target_group(self):
# When auto-sync routes channels into a different group via
# group_override, the occupant's channel_group_id is the override
# target, not the source group being configured. The frontend relies
# on this to recognize override-routed channels as the config's own
# output (effectiveSyncGroupId), so the warning does not flag them.
account = _make_account()
source = _make_group(name="SourceGrp")
target = _make_group(name="TargetGrp")
Channel.objects.create(
name="Routed",
channel_number=3210,
channel_group=target,
auto_created=True,
auto_created_by=account,
)
client = self._client()
response = client.get(
"/api/channels/channels/numbers-in-range/?start=3210&end=3210"
)
occupant = response.data["occupants"][0]
self.assertEqual(occupant["channel_group_id"], target.id)
self.assertNotEqual(occupant["channel_group_id"], source.id)
self.assertTrue(occupant["auto_created"])
self.assertEqual(occupant["auto_created_by_account_id"], account.id)
def test_manual_channel_exposed_with_auto_created_false(self):
# Manual channels are always a real collision worth surfacing.
# The response must flag them with auto_created=False and a null

View file

@ -34,6 +34,7 @@ import {
getRegexOptions,
getStreamsRegexPreview,
isExpectedOccupantForGroup,
effectiveSyncGroupId,
isGroupVisible,
rangeFor,
} from '../../utils/forms/LiveGroupFilterUtils.js';
@ -131,7 +132,12 @@ const LiveGroupFilter = ({
// (in-memory range overlap with sibling groups) sources so the sweep
// can refresh form-overlap synchronously without firing HTTP for
// groups that did not change.
const scheduleConflictScan = (groupId, rawStart, rawEnd) => {
const scheduleConflictScan = (
groupId,
rawStart,
rawEnd,
expectedGroupId = groupId
) => {
if (conflictTimersRef.current[groupId]) {
clearTimeout(conflictTimersRef.current[groupId]);
}
@ -156,7 +162,7 @@ const LiveGroupFilter = ({
? result.occupants
: [];
const unexpected = occupants.filter(
(o) => !isExpectedOccupantForGroup(o, groupId, playlist)
(o) => !isExpectedOccupantForGroup(o, expectedGroupId, playlist)
);
setConflictSource(groupId, 'occupant', unexpected.length > 0);
} catch (e) {
@ -221,7 +227,8 @@ const LiveGroupFilter = ({
scheduleConflictScan(
g.channel_group,
range.startRaw,
g.auto_sync_channel_end
g.auto_sync_channel_end,
effectiveSyncGroupId(g)
);
}
}

View file

@ -57,6 +57,18 @@ export const isExpectedOccupantForGroup = (
);
};
// The group the sync's own channels actually land in. A group_override
// routes auto-created channels into a different ChannelGroup, so the
// conflict check must recognize occupants of that target group as this
// config's own output rather than flagging them against the source group.
export const effectiveSyncGroupId = (group) => {
const override = group?.custom_properties?.group_override;
if (override !== undefined && override !== null && override !== '') {
return Number(override);
}
return group?.channel_group;
};
export const rangeFor = (g) => {
if (!g.enabled || !g.auto_channel_sync) return null;
const mode = g.custom_properties?.channel_numbering_mode || 'fixed';

View file

@ -4,6 +4,7 @@ import {
getChannelsInRange,
getStreamsRegexPreview,
isExpectedOccupantForGroup,
effectiveSyncGroupId,
rangeFor,
abortTimers,
getRegexOptions,
@ -227,6 +228,116 @@ describe('LiveGroupFilterUtils', () => {
});
});
// ── effectiveSyncGroupId ───────────────────────────────────────────────────
describe('effectiveSyncGroupId', () => {
it('returns the source channel_group when there is no override', () => {
expect(effectiveSyncGroupId(makeGroup({ channel_group: 7 }))).toBe(7);
});
it('returns the group_override target when set', () => {
const group = makeGroup({
channel_group: 7,
custom_properties: { group_override: 9 },
});
expect(effectiveSyncGroupId(group)).toBe(9);
});
it('coerces a string-stored group_override to a number', () => {
const group = makeGroup({
channel_group: 7,
custom_properties: { group_override: '9' },
});
expect(effectiveSyncGroupId(group)).toBe(9);
});
it('falls back to the source group when group_override is blank', () => {
const group = makeGroup({
channel_group: 7,
custom_properties: { group_override: '' },
});
expect(effectiveSyncGroupId(group)).toBe(7);
});
// Regression guard for the group-override range-conflict false positive:
// the auto-sync's own channels land in the override target group, so
// comparing against the source group (pre-fix) flags them as a conflict,
// while comparing against the effective target recognizes them as this
// config's own output.
it("makes group-override occupants count as this group's own", () => {
const group = makeGroup({
channel_group: 7,
custom_properties: { group_override: 9 },
});
const occupant = makeOccupant({ channel_group_id: 9 });
// Pre-fix comparison (source group) treats own channels as a conflict.
expect(
isExpectedOccupantForGroup(
occupant,
group.channel_group,
makePlaylist()
)
).toBe(false);
// Comparing against the effective target recognizes them as expected.
expect(
isExpectedOccupantForGroup(
occupant,
effectiveSyncGroupId(group),
makePlaylist()
)
).toBe(true);
});
// Guards against over-suppression: resolving the effective target group
// must still surface genuine collisions in an override config's range.
// Only the config's own output (auto-created, this account, in the
// target group, unpinned) is excluded.
it('still flags genuine collisions in a group-override config', () => {
const group = makeGroup({
channel_group: 7,
custom_properties: { group_override: 9 },
});
const target = effectiveSyncGroupId(group);
// Manual channel sitting in the range.
expect(
isExpectedOccupantForGroup(
makeOccupant({ channel_group_id: 9, auto_created: false }),
target,
makePlaylist()
)
).toBe(false);
// Auto-created by a different account.
expect(
isExpectedOccupantForGroup(
makeOccupant({
channel_group_id: 9,
auto_created_by_account_id: 999,
}),
target,
makePlaylist()
)
).toBe(false);
// A channel in a different group than the override target.
expect(
isExpectedOccupantForGroup(
makeOccupant({ channel_group_id: 123 }),
target,
makePlaylist()
)
).toBe(false);
// A user-pinned channel number.
expect(
isExpectedOccupantForGroup(
makeOccupant({
channel_group_id: 9,
has_channel_number_override: true,
}),
target,
makePlaylist()
)
).toBe(false);
});
});
// ── rangeFor ──────────────────────────────────────────────────────────────
describe('rangeFor', () => {
it('returns null when group is disabled', () => {