@dpesch/mantisbt-mcp-server 1.8.3 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import { registerIssueTools } from '../../src/tools/issues.js';
7
7
  import { MetadataCache } from '../../src/cache.js';
8
8
  import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
9
9
  import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
10
+ import { clearIssueEnumCache } from '../../src/tools/config.js';
10
11
 
11
12
  function makeStubCache(projectUsers?: Array<{ id: number; name: string; real_name?: string }>): MetadataCache {
12
13
  return {
@@ -31,7 +32,7 @@ const getIssueFixturePath = join(fixturesDir, 'get_issue.json');
31
32
  const listIssuesFixturePath = join(fixturesDir, 'list_issues.json');
32
33
 
33
34
  const getIssueFixture = existsSync(getIssueFixturePath)
34
- ? (JSON.parse(readFileSync(getIssueFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }> })
35
+ ? (JSON.parse(readFileSync(getIssueFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; notes?: Array<{ id: number }> }> })
35
36
  : { issues: [{ id: 42, summary: 'Test Issue' }] };
36
37
 
37
38
  const listIssuesFixture = existsSync(listIssuesFixturePath)
@@ -55,6 +56,7 @@ beforeEach(() => {
55
56
  client = new MantisClient('https://mantis.example.com', 'test-token');
56
57
  registerIssueTools(mockServer as never, client, makeStubCache());
57
58
  vi.stubGlobal('fetch', vi.fn());
59
+ clearIssueEnumCache();
58
60
  });
59
61
 
60
62
  afterEach(() => {
@@ -101,6 +103,139 @@ describe('get_issue', () => {
101
103
  expect(result.isError).toBe(true);
102
104
  expect(result.content[0]!.text).toContain('Error:');
103
105
  });
106
+
107
+ it('enthält notes direkt in der Response (kein separater list_notes-Call nötig)', async () => {
108
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getIssueFixture)));
109
+
110
+ const result = await mockServer.callTool('get_issue', { id: getIssueFixture.issues[0]!.id });
111
+
112
+ expect(result.isError).toBeUndefined();
113
+ const parsed = JSON.parse(result.content[0]!.text) as { notes?: Array<{ id: number }> };
114
+ const fixtureNotes = getIssueFixture.issues[0]!.notes!;
115
+ expect(Array.isArray(parsed.notes)).toBe(true);
116
+ expect(parsed.notes!.length).toBe(fixtureNotes.length);
117
+ expect(parsed.notes![0]!.id).toBe(fixtureNotes[0]!.id);
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // get_issues
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('get_issues', () => {
126
+ it('ist registriert', () => {
127
+ expect(mockServer.hasToolRegistered('get_issues')).toBe(true);
128
+ });
129
+
130
+ it('gibt ein Array von Issues zurück wenn alle IDs existieren', async () => {
131
+ vi.mocked(fetch)
132
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 42, summary: 'Issue 42' }] })))
133
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 43, summary: 'Issue 43' }] })));
134
+
135
+ const result = await mockServer.callTool('get_issues', { ids: [42, 43] });
136
+
137
+ expect(result.isError).toBeUndefined();
138
+ const parsed = JSON.parse(result.content[0]!.text) as {
139
+ issues: Array<{ id: number } | null>;
140
+ requested: number;
141
+ found: number;
142
+ failed: number;
143
+ };
144
+ expect(parsed.requested).toBe(2);
145
+ expect(parsed.found).toBe(2);
146
+ expect(parsed.failed).toBe(0);
147
+ expect(parsed.issues[0]?.id).toBe(42);
148
+ expect(parsed.issues[1]?.id).toBe(43);
149
+ });
150
+
151
+ it('gibt null für nicht gefundene IDs zurück ohne isError zu setzen', async () => {
152
+ vi.mocked(fetch)
153
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 42, summary: 'Issue 42' }] })))
154
+ .mockResolvedValueOnce(makeResponse(404, JSON.stringify({ message: 'Issue not found' })));
155
+
156
+ const result = await mockServer.callTool('get_issues', { ids: [42, 9999] });
157
+
158
+ expect(result.isError).toBeUndefined();
159
+ const parsed = JSON.parse(result.content[0]!.text) as {
160
+ issues: Array<{ id: number } | null>;
161
+ found: number;
162
+ failed: number;
163
+ };
164
+ expect(parsed.found).toBe(1);
165
+ expect(parsed.failed).toBe(1);
166
+ expect(parsed.issues[0]?.id).toBe(42);
167
+ expect(parsed.issues[1]).toBeNull();
168
+ });
169
+
170
+ it('gibt isError nicht zurück auch wenn alle IDs fehlschlagen', async () => {
171
+ vi.mocked(fetch).mockResolvedValue(
172
+ makeResponse(404, JSON.stringify({ message: 'Not found' })),
173
+ );
174
+
175
+ const result = await mockServer.callTool('get_issues', { ids: [9001, 9002] });
176
+
177
+ expect(result.isError).toBeUndefined();
178
+ const parsed = JSON.parse(result.content[0]!.text) as {
179
+ issues: Array<null>;
180
+ found: number;
181
+ failed: number;
182
+ };
183
+ expect(parsed.found).toBe(0);
184
+ expect(parsed.failed).toBe(2);
185
+ expect(parsed.issues).toEqual([null, null]);
186
+ });
187
+
188
+ it('erhält die Reihenfolge der Input-IDs im Ergebnis-Array', async () => {
189
+ vi.mocked(fetch)
190
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 10, summary: 'A' }] })))
191
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 20, summary: 'B' }] })))
192
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 30, summary: 'C' }] })));
193
+
194
+ const result = await mockServer.callTool('get_issues', { ids: [10, 20, 30] });
195
+
196
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
197
+ expect(parsed.issues.map((i) => i.id)).toEqual([10, 20, 30]);
198
+ });
199
+
200
+ it('validiert: leeres ids-Array wird abgelehnt', async () => {
201
+ const result = await mockServer.callTool('get_issues', { ids: [] }, { validate: true });
202
+ expect(result.isError).toBe(true);
203
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it('validiert: mehr als 50 IDs werden abgelehnt', async () => {
207
+ const ids = Array.from({ length: 51 }, (_, i) => i + 1);
208
+ const result = await mockServer.callTool('get_issues', { ids }, { validate: true });
209
+ expect(result.isError).toBe(true);
210
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('akzeptiert String-IDs via z.coerce', async () => {
214
+ vi.mocked(fetch).mockResolvedValue(
215
+ makeResponse(200, JSON.stringify({ issues: [{ id: 42, summary: 'Test' }] })),
216
+ );
217
+
218
+ const result = await mockServer.callTool('get_issues', { ids: ['42'] }, { validate: true });
219
+
220
+ expect(result.isError).toBeUndefined();
221
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
222
+ expect(parsed.issues[0]?.id).toBe(42);
223
+ });
224
+
225
+ it('führt alle Requests aus (Concurrency-Nachweis mit 6 IDs)', async () => {
226
+ Array.from({ length: 6 }, (_, i) => {
227
+ vi.mocked(fetch).mockResolvedValueOnce(
228
+ makeResponse(200, JSON.stringify({ issues: [{ id: i + 1, summary: 'x' }] })),
229
+ );
230
+ });
231
+
232
+ const result = await mockServer.callTool('get_issues', { ids: [1, 2, 3, 4, 5, 6] });
233
+
234
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(6);
235
+ expect(result.isError).toBeUndefined();
236
+ const parsed = JSON.parse(result.content[0]!.text) as { requested: number };
237
+ expect(parsed.requested).toBe(6);
238
+ });
104
239
  });
105
240
 
106
241
  // ---------------------------------------------------------------------------
@@ -235,7 +370,6 @@ describe('create_issue', () => {
235
370
  expect(result.isError).toBe(true);
236
371
  expect(result.content[0]!.text).toContain('schwerer Fehler');
237
372
  expect(result.content[0]!.text).toContain('minor');
238
- expect(fetch).not.toHaveBeenCalled();
239
373
  });
240
374
 
241
375
  it('creates issue without any optional fields (backward compatibility)', async () => {
@@ -330,7 +464,6 @@ describe('create_issue', () => {
330
464
  expect(result.isError).toBe(true);
331
465
  expect(result.content[0]!.text).toContain('immer');
332
466
  expect(result.content[0]!.text).toContain('always');
333
- expect(fetch).not.toHaveBeenCalled();
334
467
  });
335
468
 
336
469
  it('returns error for empty reproducibility string', async () => {
@@ -343,7 +476,6 @@ describe('create_issue', () => {
343
476
  }, { validate: true });
344
477
 
345
478
  expect(result.isError).toBe(true);
346
- expect(fetch).not.toHaveBeenCalled();
347
479
  });
348
480
 
349
481
  it('sends view_state as { name } object', async () => {
@@ -655,6 +787,45 @@ describe('list_issues', () => {
655
787
  expect(parsedUpper.issues.length).toBe(parsedLower.issues.length);
656
788
  expect(parsedUpper.issues).toEqual(parsedLower.issues);
657
789
  });
790
+
791
+ it('status canonical name matches by ID even when API returns localized status names', async () => {
792
+ // Simulate a German MantisBT installation where issue.status.name is localized
793
+ const localizedFixture = {
794
+ issues: [
795
+ { id: 1, status: { id: 10, name: 'Neu' } },
796
+ { id: 2, status: { id: 80, name: 'Erledigt' } },
797
+ { id: 3, status: { id: 10, name: 'Neu' } },
798
+ ],
799
+ total_count: 3,
800
+ };
801
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(localizedFixture)));
802
+
803
+ const result = await mockServer.callTool('list_issues', { status: 'new', page: 1, page_size: 50 });
804
+
805
+ expect(result.isError).toBeUndefined();
806
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
807
+ expect(parsed.issues).toHaveLength(2);
808
+ expect(parsed.issues.map(i => i.id)).toEqual([1, 3]);
809
+ });
810
+
811
+ it('status filter falls back to name comparison for non-canonical values', async () => {
812
+ // "Neu" is not a canonical status name → falls back to name comparison
813
+ const localizedFixture = {
814
+ issues: [
815
+ { id: 1, status: { id: 10, name: 'Neu' } },
816
+ { id: 2, status: { id: 80, name: 'Erledigt' } },
817
+ ],
818
+ total_count: 2,
819
+ };
820
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(localizedFixture)));
821
+
822
+ const result = await mockServer.callTool('list_issues', { status: 'Neu', page: 1, page_size: 50 });
823
+
824
+ expect(result.isError).toBeUndefined();
825
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
826
+ expect(parsed.issues).toHaveLength(1);
827
+ expect(parsed.issues[0]!.id).toBe(1);
828
+ });
658
829
  });
659
830
 
660
831
  // ---------------------------------------------------------------------------
@@ -736,3 +907,274 @@ describe('update_issue – fields allowlist', () => {
736
907
  expect(vi.mocked(fetch)).not.toHaveBeenCalled();
737
908
  });
738
909
  });
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // update_issue – dry_run
913
+ // ---------------------------------------------------------------------------
914
+
915
+ describe('update_issue – dry_run', () => {
916
+ it('returns preview payload without calling the API', async () => {
917
+ const result = await mockServer.callTool(
918
+ 'update_issue',
919
+ { id: 42, fields: { summary: 'Preview title', status: { name: 'resolved' } }, dry_run: true },
920
+ { validate: true },
921
+ );
922
+
923
+ expect(result.isError).toBeUndefined();
924
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
925
+
926
+ const parsed = JSON.parse(result.content[0]!.text) as { dry_run: boolean; id: number; would_patch: Record<string, unknown> };
927
+ expect(parsed.dry_run).toBe(true);
928
+ expect(parsed.id).toBe(42);
929
+ expect(parsed.would_patch).toEqual({ summary: 'Preview title', status: { name: 'resolved' } });
930
+ });
931
+
932
+ it('defaults to false (normal update) when dry_run is omitted', async () => {
933
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issue: { id: 42, summary: 'Updated' } })));
934
+
935
+ const result = await mockServer.callTool(
936
+ 'update_issue',
937
+ { id: 42, fields: { summary: 'Updated' } },
938
+ { validate: true },
939
+ );
940
+
941
+ expect(result.isError).toBeUndefined();
942
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
943
+ });
944
+
945
+ it('also rejects unknown fields in dry_run mode without calling the API', async () => {
946
+ const result = await mockServer.callTool(
947
+ 'update_issue',
948
+ { id: 42, fields: { unknown_field: 'bad' }, dry_run: true },
949
+ { validate: true },
950
+ );
951
+
952
+ expect(result.isError).toBe(true);
953
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
954
+ });
955
+ });
956
+
957
+ // ---------------------------------------------------------------------------
958
+ // update_issue – enum resolution
959
+ // ---------------------------------------------------------------------------
960
+
961
+ describe('update_issue – enum resolution', () => {
962
+ it('resolves canonical severity name to { id } before sending to API', async () => {
963
+ vi.mocked(fetch).mockResolvedValue(
964
+ makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
965
+ );
966
+
967
+ await mockServer.callTool(
968
+ 'update_issue',
969
+ { id: 1, fields: { severity: { name: 'major' } } },
970
+ { validate: true },
971
+ );
972
+
973
+ // Only one fetch call (the PATCH itself — canonical lookup needs no API call)
974
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
975
+ const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
976
+ expect(body.severity).toEqual({ id: 60 }); // major = id 60
977
+ });
978
+
979
+ it('resolves canonical priority name to { id } before sending to API', async () => {
980
+ vi.mocked(fetch).mockResolvedValue(
981
+ makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
982
+ );
983
+
984
+ await mockServer.callTool(
985
+ 'update_issue',
986
+ { id: 1, fields: { priority: { name: 'high' } } },
987
+ { validate: true },
988
+ );
989
+
990
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
991
+ const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
992
+ expect(body.priority).toEqual({ id: 40 }); // high = id 40
993
+ });
994
+
995
+ it('resolves canonical status name to { id } before sending to API', async () => {
996
+ vi.mocked(fetch).mockResolvedValue(
997
+ makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
998
+ );
999
+
1000
+ await mockServer.callTool(
1001
+ 'update_issue',
1002
+ { id: 1, fields: { status: { name: 'resolved' } } },
1003
+ { validate: true },
1004
+ );
1005
+
1006
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
1007
+ const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
1008
+ expect(body.status).toEqual({ id: 80 }); // resolved = id 80
1009
+ });
1010
+
1011
+ it('does not resolve when id is already provided', async () => {
1012
+ vi.mocked(fetch).mockResolvedValue(
1013
+ makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
1014
+ );
1015
+
1016
+ await mockServer.callTool(
1017
+ 'update_issue',
1018
+ { id: 1, fields: { severity: { id: 60 } } },
1019
+ { validate: true },
1020
+ );
1021
+
1022
+ // Only one fetch call — no enum resolution needed
1023
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
1024
+ const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
1025
+ expect(body.severity).toEqual({ id: 60 });
1026
+ });
1027
+
1028
+ it('resolves localized enum name to { id } via live enum lookup', async () => {
1029
+ // First fetch: config endpoint for enum data
1030
+ // Second fetch: the actual PATCH
1031
+ vi.mocked(fetch)
1032
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({
1033
+ configs: [{
1034
+ option: 'severity_enum_string',
1035
+ value: [
1036
+ { id: 50, name: 'Kleiner Fehler' },
1037
+ { id: 60, name: 'Großer Fehler' },
1038
+ ],
1039
+ }],
1040
+ })))
1041
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issue: { id: 1 } })));
1042
+
1043
+ await mockServer.callTool(
1044
+ 'update_issue',
1045
+ { id: 1, fields: { severity: { name: 'Großer Fehler' } } },
1046
+ { validate: true },
1047
+ );
1048
+
1049
+ // Call 0 = config endpoint, Call 1 = PATCH
1050
+ const patchBody = JSON.parse(vi.mocked(fetch).mock.calls[1]![1]!.body as string) as Record<string, unknown>;
1051
+ expect(patchBody.severity).toEqual({ id: 60 });
1052
+ });
1053
+
1054
+ it('passes unknown enum name through unchanged when resolution fails', async () => {
1055
+ // Config returns no useful data → name stays unchanged
1056
+ vi.mocked(fetch)
1057
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ configs: [] })))
1058
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issue: { id: 1 } })));
1059
+
1060
+ await mockServer.callTool(
1061
+ 'update_issue',
1062
+ { id: 1, fields: { severity: { name: 'völlig_unbekannt' } } },
1063
+ { validate: true },
1064
+ );
1065
+
1066
+ const patchBody = JSON.parse(vi.mocked(fetch).mock.calls[1]![1]!.body as string) as Record<string, unknown>;
1067
+ expect(patchBody.severity).toEqual({ name: 'völlig_unbekannt' });
1068
+ });
1069
+
1070
+ it('resolves multiple enum fields in one patch call', async () => {
1071
+ vi.mocked(fetch).mockResolvedValue(
1072
+ makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
1073
+ );
1074
+
1075
+ await mockServer.callTool(
1076
+ 'update_issue',
1077
+ { id: 1, fields: { status: { name: 'resolved' }, priority: { name: 'high' }, severity: { name: 'major' } } },
1078
+ { validate: true },
1079
+ );
1080
+
1081
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce(); // all canonical → single PATCH call
1082
+ const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
1083
+ expect(body.status).toEqual({ id: 80 });
1084
+ expect(body.priority).toEqual({ id: 40 });
1085
+ expect(body.severity).toEqual({ id: 60 });
1086
+ });
1087
+ });
1088
+
1089
+ // ---------------------------------------------------------------------------
1090
+ // list_issues – date filters
1091
+ // ---------------------------------------------------------------------------
1092
+
1093
+ function makeIssuePage(issues: Array<{ id: number; updated_at: string; created_at: string }>) {
1094
+ return { issues, total_count: issues.length };
1095
+ }
1096
+
1097
+ describe('list_issues – updated_after filter', () => {
1098
+ it('returns only issues updated after the given date', async () => {
1099
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
1100
+ { id: 1, updated_at: '2026-03-26T10:00:00Z', created_at: '2026-03-01T00:00:00Z' },
1101
+ { id: 2, updated_at: '2026-03-23T10:00:00Z', created_at: '2026-03-01T00:00:00Z' },
1102
+ { id: 3, updated_at: '2026-03-25T00:00:01Z', created_at: '2026-03-01T00:00:00Z' },
1103
+ ]))));
1104
+
1105
+ const result = await mockServer.callTool('list_issues', {
1106
+ updated_after: '2026-03-25T00:00:00Z',
1107
+ });
1108
+
1109
+ expect(result.isError).toBeUndefined();
1110
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
1111
+ expect(parsed.issues.map(i => i.id)).toEqual([1, 3]);
1112
+ });
1113
+
1114
+ it('returns an empty list when no issue matches', async () => {
1115
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
1116
+ { id: 1, updated_at: '2026-03-20T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
1117
+ ]))));
1118
+
1119
+ const result = await mockServer.callTool('list_issues', {
1120
+ updated_after: '2026-03-25T00:00:00Z',
1121
+ });
1122
+
1123
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<unknown> };
1124
+ expect(parsed.issues).toHaveLength(0);
1125
+ });
1126
+
1127
+ it('stops scanning pages when all issues in a batch are older than updated_after (early-exit)', async () => {
1128
+ // Page 1: one matching issue, one too old
1129
+ // Page 2: should NOT be fetched because the last item of page 1 is already older
1130
+ vi.mocked(fetch)
1131
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify(makeIssuePage([
1132
+ { id: 10, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
1133
+ { id: 9, updated_at: '2026-03-20T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
1134
+ ]))))
1135
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify(makeIssuePage([
1136
+ { id: 8, updated_at: '2026-03-18T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
1137
+ ]))));
1138
+
1139
+ await mockServer.callTool('list_issues', {
1140
+ updated_after: '2026-03-25T00:00:00Z',
1141
+ page_size: 50,
1142
+ });
1143
+
1144
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
1145
+ });
1146
+ });
1147
+
1148
+ describe('list_issues – created_after filter', () => {
1149
+ it('returns only issues created after the given date', async () => {
1150
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
1151
+ { id: 1, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-26T00:00:00Z' },
1152
+ { id: 2, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-23T00:00:00Z' },
1153
+ ]))));
1154
+
1155
+ const result = await mockServer.callTool('list_issues', {
1156
+ created_after: '2026-03-25T00:00:00Z',
1157
+ });
1158
+
1159
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
1160
+ expect(parsed.issues.map(i => i.id)).toEqual([1]);
1161
+ });
1162
+ });
1163
+
1164
+ describe('list_issues – combined date window', () => {
1165
+ it('returns only issues within the updated_after + updated_before window', async () => {
1166
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
1167
+ { id: 1, updated_at: '2026-03-22T00:00:00Z', created_at: '2026-03-01T00:00:00Z' }, // in window
1168
+ { id: 2, updated_at: '2026-03-19T00:00:00Z', created_at: '2026-03-01T00:00:00Z' }, // too old
1169
+ { id: 3, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-01T00:00:00Z' }, // too new
1170
+ ]))));
1171
+
1172
+ const result = await mockServer.callTool('list_issues', {
1173
+ updated_after: '2026-03-20T00:00:00Z',
1174
+ updated_before: '2026-03-25T00:00:00Z',
1175
+ });
1176
+
1177
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
1178
+ expect(parsed.issues.map(i => i.id)).toEqual([1]);
1179
+ });
1180
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { matchesDateFilter } from '../../src/date-filter.js';
3
+
4
+ describe('matchesDateFilter', () => {
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // No filter → always pass
8
+ // ---------------------------------------------------------------------------
9
+
10
+ it('returns true when no filter is set', () => {
11
+ expect(matchesDateFilter(
12
+ { updated_at: '2026-03-20T10:00:00Z', created_at: '2026-03-01T00:00:00Z' },
13
+ {}
14
+ )).toBe(true);
15
+ });
16
+
17
+ it('returns true for an item with no dates when no filter is set', () => {
18
+ expect(matchesDateFilter({}, {})).toBe(true);
19
+ });
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // updated_after
23
+ // ---------------------------------------------------------------------------
24
+
25
+ it('updated_after: passes when updated_at is after the threshold', () => {
26
+ expect(matchesDateFilter(
27
+ { updated_at: '2026-03-25T12:00:00Z' },
28
+ { updated_after: '2026-03-24T00:00:00Z' }
29
+ )).toBe(true);
30
+ });
31
+
32
+ it('updated_after: fails when updated_at is before the threshold', () => {
33
+ expect(matchesDateFilter(
34
+ { updated_at: '2026-03-23T12:00:00Z' },
35
+ { updated_after: '2026-03-24T00:00:00Z' }
36
+ )).toBe(false);
37
+ });
38
+
39
+ it('updated_after: fails when updated_at equals the threshold (exclusive)', () => {
40
+ expect(matchesDateFilter(
41
+ { updated_at: '2026-03-24T00:00:00Z' },
42
+ { updated_after: '2026-03-24T00:00:00Z' }
43
+ )).toBe(false);
44
+ });
45
+
46
+ it('updated_after: fails when updated_at is missing', () => {
47
+ expect(matchesDateFilter(
48
+ { created_at: '2026-03-25T00:00:00Z' },
49
+ { updated_after: '2026-03-24T00:00:00Z' }
50
+ )).toBe(false);
51
+ });
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // updated_before
55
+ // ---------------------------------------------------------------------------
56
+
57
+ it('updated_before: passes when updated_at is before the threshold', () => {
58
+ expect(matchesDateFilter(
59
+ { updated_at: '2026-03-20T00:00:00Z' },
60
+ { updated_before: '2026-03-25T00:00:00Z' }
61
+ )).toBe(true);
62
+ });
63
+
64
+ it('updated_before: fails when updated_at is after the threshold', () => {
65
+ expect(matchesDateFilter(
66
+ { updated_at: '2026-03-26T00:00:00Z' },
67
+ { updated_before: '2026-03-25T00:00:00Z' }
68
+ )).toBe(false);
69
+ });
70
+
71
+ it('updated_before: fails when updated_at equals the threshold (exclusive)', () => {
72
+ expect(matchesDateFilter(
73
+ { updated_at: '2026-03-25T00:00:00Z' },
74
+ { updated_before: '2026-03-25T00:00:00Z' }
75
+ )).toBe(false);
76
+ });
77
+
78
+ it('updated_before: fails when updated_at is missing', () => {
79
+ expect(matchesDateFilter(
80
+ {},
81
+ { updated_before: '2026-03-25T00:00:00Z' }
82
+ )).toBe(false);
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // created_after
87
+ // ---------------------------------------------------------------------------
88
+
89
+ it('created_after: passes when created_at is after the threshold', () => {
90
+ expect(matchesDateFilter(
91
+ { created_at: '2026-03-25T12:00:00Z' },
92
+ { created_after: '2026-03-24T00:00:00Z' }
93
+ )).toBe(true);
94
+ });
95
+
96
+ it('created_after: fails when created_at is before the threshold', () => {
97
+ expect(matchesDateFilter(
98
+ { created_at: '2026-03-23T00:00:00Z' },
99
+ { created_after: '2026-03-24T00:00:00Z' }
100
+ )).toBe(false);
101
+ });
102
+
103
+ it('created_after: fails when created_at is missing', () => {
104
+ expect(matchesDateFilter(
105
+ { updated_at: '2026-03-25T00:00:00Z' },
106
+ { created_after: '2026-03-24T00:00:00Z' }
107
+ )).toBe(false);
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // created_before
112
+ // ---------------------------------------------------------------------------
113
+
114
+ it('created_before: passes when created_at is before the threshold', () => {
115
+ expect(matchesDateFilter(
116
+ { created_at: '2026-03-20T00:00:00Z' },
117
+ { created_before: '2026-03-25T00:00:00Z' }
118
+ )).toBe(true);
119
+ });
120
+
121
+ it('created_before: fails when created_at is after the threshold', () => {
122
+ expect(matchesDateFilter(
123
+ { created_at: '2026-03-26T00:00:00Z' },
124
+ { created_before: '2026-03-25T00:00:00Z' }
125
+ )).toBe(false);
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Combined filters (time window)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ it('updated_after + updated_before: passes when updated_at is within window', () => {
133
+ expect(matchesDateFilter(
134
+ { updated_at: '2026-03-22T00:00:00Z' },
135
+ { updated_after: '2026-03-20T00:00:00Z', updated_before: '2026-03-25T00:00:00Z' }
136
+ )).toBe(true);
137
+ });
138
+
139
+ it('updated_after + updated_before: fails when updated_at is outside window', () => {
140
+ expect(matchesDateFilter(
141
+ { updated_at: '2026-03-26T00:00:00Z' },
142
+ { updated_after: '2026-03-20T00:00:00Z', updated_before: '2026-03-25T00:00:00Z' }
143
+ )).toBe(false);
144
+ });
145
+
146
+ it('all four filters: passes only when both dates are within their windows', () => {
147
+ expect(matchesDateFilter(
148
+ { updated_at: '2026-03-22T00:00:00Z', created_at: '2026-03-10T00:00:00Z' },
149
+ {
150
+ updated_after: '2026-03-20T00:00:00Z',
151
+ updated_before: '2026-03-25T00:00:00Z',
152
+ created_after: '2026-03-05T00:00:00Z',
153
+ created_before: '2026-03-15T00:00:00Z',
154
+ }
155
+ )).toBe(true);
156
+ });
157
+
158
+ it('all four filters: fails when one date is out of range', () => {
159
+ expect(matchesDateFilter(
160
+ { updated_at: '2026-03-22T00:00:00Z', created_at: '2026-03-16T00:00:00Z' }, // created_at too late
161
+ {
162
+ updated_after: '2026-03-20T00:00:00Z',
163
+ updated_before: '2026-03-25T00:00:00Z',
164
+ created_after: '2026-03-05T00:00:00Z',
165
+ created_before: '2026-03-15T00:00:00Z',
166
+ }
167
+ )).toBe(false);
168
+ });
169
+ });