godcrm/backend/routes/v3/widgets/__tests__/resolveTicketsController.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

523 lines
19 KiB
JavaScript

// Integration tests for POST /api/v3/widgets/:widgetId/resolve-tickets.
//
// Mirrors the style of resolveTicketRefController.test.js: a mini express
// app mounts `buildResolveTicketsRouter` with mocked DB / widget loaders so
// the controller's filter-active dispatch + column-filter contract can be
// exercised without a real DB.
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { buildResolveTicketsRouter } from '../resolveTicketsController.js';
function buildApp({
widgets = {},
columnsByTable = {}, // { tableId: [{ id, column_name }] } — full column list
rowsByTable = {}, // { tableId: [row, row, ...] } — used for `all`/`table_all`
rowsByColumnValue = {}, // { `${tableId}:${columnName}:${value}`: [rows] } — column-filter
tableMeta = {}, // { tableId: { id, space_id } } — universal_tables row
} = {}) {
const dbGetMock = async (sql, params) => {
if (/FROM table_columns WHERE table_id = \? AND column_name = \?/i.test(sql)) {
const [tableId, columnName] = params;
const cols = columnsByTable[tableId] || [];
const found = cols.find(c => c.column_name === columnName);
return found ? { id: found.id } : null;
}
if (/FROM universal_tables/i.test(sql)) {
const [tableId] = params;
return tableMeta[tableId] || null;
}
if (/COUNT\(\*\)/i.test(sql)) {
const [tableId] = params;
const rows = rowsByTable[tableId] || [];
return { c: rows.length };
}
return null;
};
const dbAllMock = async (sql, params) => {
if (/SELECT id, column_name FROM table_columns WHERE table_id = \?/i.test(sql)) {
const [tableId] = params;
return columnsByTable[tableId] || [];
}
if (/FROM table_rows[\s\S]*data::jsonb ->> \?/i.test(sql)) {
const [tableId, columnName, value] = params;
const key = `${tableId}:${columnName}:${value}`;
return rowsByColumnValue[key] || [];
}
if (/AND id = ANY\(\?::int\[\]\)/i.test(sql)) {
// Multi-id branch: preserves order of `ids` via array_position().
const [tableId, ids /* array param for ANY */, orderIds] = params;
const order = Array.isArray(orderIds) ? orderIds : ids;
const rows = rowsByTable[tableId] || [];
const byId = new Map(rows.map(r => [Number(r.id), r]));
return order
.map(n => byId.get(Number(n)))
.filter(Boolean);
}
if (/FROM table_rows[\s\S]*WHERE table_id = \?[\s\S]*AND id = \?/i.test(sql)) {
const [tableId, rowId] = params;
const rows = rowsByTable[tableId] || [];
return rows.filter(r => Number(r.id) === Number(rowId));
}
if (/FROM table_rows[\s\S]*ORDER BY id DESC[\s\S]*LIMIT/i.test(sql)) {
const [tableId, limit, offset] = params;
const rows = rowsByTable[tableId] || [];
return rows.slice(offset, offset + limit);
}
return [];
};
const loadWidget = async (id) => widgets[id] ?? null;
const app = express();
app.use(express.json());
app.use(buildResolveTicketsRouter({
dbGet: dbGetMock,
dbAll: dbAllMock,
loadWidget,
}));
return app;
}
describe('POST /widgets/:widgetId/resolve-tickets', () => {
it('column filter with literal value returns matching tickets only', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'adr_ref', value: '129897' },
},
},
};
const columnsByTable = {
1708: [
{ id: 'col-adr', column_name: 'adr_ref' },
{ id: 'col-what', column_name: 'what' },
],
};
const rowsByColumnValue = {
'1708:adr_ref:129897': [
{
id: 1,
base_id: 'b1',
table_id: 1708,
data: { adr_ref: '129897', what: 'Match A' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
{
id: 2,
base_id: 'b2',
table_id: 1708,
data: { adr_ref: '129897', what: 'Match B' },
created_at: '2026-04-25T01:00:00.000Z',
updated_at: '2026-04-25T01:00:00.000Z',
},
],
};
const app = buildApp({ widgets, columnsByTable, rowsByColumnValue });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.total).toBe(2);
expect(res.body.data.tickets.map(t => t.data.what)).toEqual(['Match A', 'Match B']);
expect(res.body.data.applied_filter.column).toBe('adr_ref');
expect(res.body.data.applied_filter.value).toBe('129897');
});
it('column filter with use_owner_id resolves value from widget.owner_id (document owner)', async () => {
const widgets = {
300: {
id: 300,
preset_name: 'tickets_list',
owner_kind: 'document',
owner_id: 555,
config: {
tickets_table_id: 1708,
filter: { column: 'parent_doc', use_owner_id: true },
},
},
};
const columnsByTable = {
1708: [{ id: 'col-pd', column_name: 'parent_doc' }],
};
const rowsByColumnValue = {
'1708:parent_doc:555': [
{
id: 9,
base_id: 'b9',
table_id: 1708,
data: { parent_doc: '555' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
],
};
const app = buildApp({ widgets, columnsByTable, rowsByColumnValue });
const res = await request(app).post('/widgets/300/resolve-tickets').send({}).expect(200);
expect(res.body.data.total).toBe(1);
expect(res.body.data.applied_filter.value).toBe('555');
});
it('unknown column returns 400 unknown_filter_column', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'nope', value: 'x' },
},
},
};
const columnsByTable = {
1708: [{ id: 'col-adr', column_name: 'adr_ref' }],
};
const app = buildApp({ widgets, columnsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(400);
expect(res.body.error).toBe('unknown_filter_column');
expect(res.body.column).toBe('nope');
});
it('use_owner_id without document owner returns 400', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'adr_ref', use_owner_id: true },
},
},
};
const columnsByTable = { 1708: [{ id: 'col-adr', column_name: 'adr_ref' }] };
const app = buildApp({ widgets, columnsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(400);
expect(res.body.error).toBe('use_owner_id_requires_document_owner');
});
it('filter with empty string value (no use_owner_id) returns all rows from table', async () => {
// Reproduces widget 3064 in prod: stale `filter:{column:'state', value:''}`
// must not collapse the list — empty value means "filter inactive".
const widgets = {
3064: {
id: 3064,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
space_id: 4,
config: {
tickets_table_id: 1708,
filter: { column: 'state', value: '' },
},
},
};
const columnsByTable = { 1708: [{ id: 'col-what', column_name: 'what' }] };
const tableMeta = { 1708: { id: 1708, space_id: 4 } };
const rowsByTable = {
1708: [
{
id: 11,
base_id: 'b11',
table_id: 1708,
data: { 'col-what': 'A' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
{
id: 12,
base_id: 'b12',
table_id: 1708,
data: { 'col-what': 'B' },
created_at: '2026-04-25T01:00:00.000Z',
updated_at: '2026-04-25T01:00:00.000Z',
},
],
};
const app = buildApp({ widgets, columnsByTable, tableMeta, rowsByTable });
const res = await request(app).post('/widgets/3064/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('all');
expect(res.body.data.total).toBe(2);
expect(res.body.data.tickets.map(t => t.data.what).sort()).toEqual(['A', 'B']);
});
it('no filter configured returns all rows from table', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
space_id: 4,
config: { tickets_table_id: 1708 },
},
};
const columnsByTable = { 1708: [{ id: 'col-x', column_name: 'x' }] };
const tableMeta = { 1708: { id: 1708, space_id: 4 } };
const rowsByTable = {
1708: [
{
id: 1,
base_id: 'b1',
table_id: 1708,
data: { 'col-x': '1' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
],
};
const app = buildApp({ widgets, columnsByTable, tableMeta, rowsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('all');
expect(res.body.data.total).toBe(1);
});
it('relation column filter matches rows whose data is keyed by column NAME (regression)', async () => {
// Reproduces the prod bug from screenshot: priority picker saves
// {column:'priority', value:'24272'} (row_id of "medium" in table 1705).
// Tickets in prod store data under the column NAME (`priority: 24272`),
// not under the column id (`12719`). Backend MUST query by name.
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'priority', value: '24272' },
},
},
};
const columnsByTable = {
1708: [{ id: 12719, column_name: 'priority' }],
};
const rowsByColumnValue = {
'1708:priority:24272': [
{
id: 128319,
base_id: 'b128319',
table_id: 1708,
data: { priority: 24272, what: 'medium-priority ticket' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
],
};
const app = buildApp({ widgets, columnsByTable, rowsByColumnValue });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('column-filter');
expect(res.body.data.total).toBe(1);
expect(res.body.data.applied_filter.column).toBe('priority');
expect(res.body.data.applied_filter.value).toBe('24272');
});
it('id filter returns the row whose primary key equals the value (table_rows.id)', async () => {
// The pseudo-column `id` skips the table_columns existence check and
// queries by table_rows.id directly. Lets users pin a single ticket
// (e.g. for inserting it into a document by number).
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'id', value: '127904' },
},
},
};
// No need to register `id` in table_columns — controller skips that
// lookup for the pseudo-column. Other columns can still exist.
const columnsByTable = { 1708: [{ id: 'col-what', column_name: 'what' }] };
const rowsByTable = {
1708: [
{
id: 127904,
base_id: 'b127904',
table_id: 1708,
data: { what: 'POST /api/v3/.../escalate' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
{
id: 127905,
base_id: 'b127905',
table_id: 1708,
data: { what: 'unrelated' },
created_at: '2026-04-25T00:00:00.000Z',
updated_at: '2026-04-25T00:00:00.000Z',
},
],
};
const app = buildApp({ widgets, columnsByTable, rowsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('column-filter');
expect(res.body.data.total).toBe(1);
expect(res.body.data.tickets[0].id).toBe(127904);
expect(res.body.data.applied_filter.column).toBe('id');
expect(res.body.data.applied_filter.value).toBe('127904');
});
it('id filter with non-numeric value returns no rows (silent miss)', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'id', value: 'abc' },
},
},
};
const columnsByTable = { 1708: [{ id: 'col-x', column_name: 'x' }] };
const rowsByTable = { 1708: [{ id: 1, base_id: 'b1', table_id: 1708, data: {}, created_at: '', updated_at: '' }] };
const app = buildApp({ widgets, columnsByTable, rowsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('column-filter');
expect(res.body.data.total).toBe(0);
});
it('id filter with ids array returns rows in array order (manual-ids mode)', async () => {
// ADR-0012 §4.8: multi-ID atom pattern. The order of `ids` (left-to-right)
// dictates the rendered order (top-to-bottom). Backend uses
// array_position() to keep that order regardless of insert/update time.
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'id', ids: [132068, 132066, 132067] },
},
},
};
const columnsByTable = { 1708: [{ id: 'col-what', column_name: 'what' }] };
const rowsByTable = {
1708: [
{ id: 132066, base_id: 'b1', table_id: 1708, data: { what: 'first by creation' }, created_at: '', updated_at: '' },
{ id: 132067, base_id: 'b2', table_id: 1708, data: { what: 'second' }, created_at: '', updated_at: '' },
{ id: 132068, base_id: 'b3', table_id: 1708, data: { what: 'third' }, created_at: '', updated_at: '' },
{ id: 132069, base_id: 'b4', table_id: 1708, data: { what: 'unrelated' }, created_at: '', updated_at: '' },
],
};
const app = buildApp({ widgets, columnsByTable, rowsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('manual-ids');
expect(res.body.data.total).toBe(3);
expect(res.body.data.tickets.map(t => t.id)).toEqual([132068, 132066, 132067]);
expect(res.body.data.applied_filter.column).toBe('id');
expect(res.body.data.applied_filter.ids).toEqual([132068, 132066, 132067]);
});
it('id filter with ids accepts a CSV string and dedupes / drops bad entries', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
// Tolerant input — frontend sanitises but backend is the
// canonical guard against drifted UI / direct DB edits.
filter: { column: 'id', ids: '132066, 132067 , 132066 , bad,, 132068' },
},
},
};
const columnsByTable = { 1708: [{ id: 'col-what', column_name: 'what' }] };
const rowsByTable = {
1708: [
{ id: 132066, base_id: 'b1', table_id: 1708, data: { what: 'a' }, created_at: '', updated_at: '' },
{ id: 132067, base_id: 'b2', table_id: 1708, data: { what: 'b' }, created_at: '', updated_at: '' },
{ id: 132068, base_id: 'b3', table_id: 1708, data: { what: 'c' }, created_at: '', updated_at: '' },
],
};
const app = buildApp({ widgets, columnsByTable, rowsByTable });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('manual-ids');
expect(res.body.data.applied_filter.ids).toEqual([132066, 132067, 132068]);
expect(res.body.data.tickets.map(t => t.id)).toEqual([132066, 132067, 132068]);
});
it('ids on non-id column is ignored (legacy single-value path wins)', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'adr_ref', value: '129897', ids: [1, 2, 3] },
},
},
};
const columnsByTable = { 1708: [{ id: 'col-adr', column_name: 'adr_ref' }] };
const rowsByColumnValue = {
'1708:adr_ref:129897': [
{ id: 50, base_id: 'b50', table_id: 1708, data: { adr_ref: '129897' }, created_at: '', updated_at: '' },
],
};
const app = buildApp({ widgets, columnsByTable, rowsByColumnValue });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(200);
expect(res.body.data.filter_mode).toBe('column-filter');
expect(res.body.data.applied_filter.value).toBe('129897');
expect(res.body.data.applied_filter.ids).toBeUndefined();
});
it('rejects column names with non-identifier chars (defence in depth)', async () => {
const widgets = {
218: {
id: 218,
preset_name: 'tickets_list',
owner_kind: 'dashboard',
owner_id: 7,
config: {
tickets_table_id: 1708,
filter: { column: 'bad name; DROP TABLE', value: 'x' },
},
},
};
const app = buildApp({ widgets, columnsByTable: { 1708: [] } });
const res = await request(app).post('/widgets/218/resolve-tickets').send({}).expect(400);
expect(res.body.error).toBe('invalid_filter_column');
});
it('non-numeric widgetId returns 400', async () => {
const app = buildApp({});
await request(app).post('/widgets/abc/resolve-tickets').send({}).expect(400);
});
it('widget not found returns 404', async () => {
const app = buildApp({ widgets: {} });
await request(app).post('/widgets/9999/resolve-tickets').send({}).expect(404);
});
});