Skip to content

Commit d1fe17e

Browse files
kriszypclaude
andcommitted
test: extract component fixture to fixtures/custom-resources/, move to resources/custom-resources.test.ts
Addresses Ethan Arrowood's review feedback on #1211: inline SCHEMA_GRAPHQL, RESOURCES_JS, and CONFIG_YAML strings extracted to real files under integrationTests/fixtures/custom-resources/. Test moved from integrationTests/apiTests/ to integrationTests/resources/ per directory conventions. Deployment unchanged: installAppComponent still receives name→content maps, now loaded with readFileSync at test startup. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 01e1710 commit d1fe17e

4 files changed

Lines changed: 150 additions & 146 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
graphqlSchema:
2+
files: '*.graphql'
3+
jsResource:
4+
files: resources.js
5+
rest: true
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// WorkItem: async write-then-patch pattern (CDI RT enqueueing + AI inference result attachment)
2+
export class WorkItem extends tables.WorkItem {
3+
async post(body, _ctx) {
4+
const id = Math.random().toString(36).slice(2);
5+
// Use static class method to create a new record by explicit id
6+
await tables.WorkItem.put({ id, state: 'pending', payload: JSON.stringify(body) });
7+
return { id, state: 'pending' };
8+
}
9+
async patch(body, _ctx) {
10+
// this is the loaded record instance; doesExist() tells us if it was found
11+
if (!this.doesExist()) return new Response(null, { status: 404 });
12+
const current = await this.get();
13+
// Full update via single-arg super.put (legacy: update(record, true) + save)
14+
await super.put({ ...current, state: 'completed', result: body.result, resultAt: new Date() });
15+
return { state: 'completed' };
16+
}
17+
}
18+
19+
// RedirectRule: routing decision with audit trail (Walmart USGM pattern)
20+
export class RedirectRule extends tables.RedirectRule {
21+
async post(body, _ctx) {
22+
// chain detection: check if redirectUrl matches any existing matchUrl
23+
const existing = [];
24+
for await (const r of tables.RedirectRule.search({
25+
conditions: [{ attribute: 'matchUrl', value: body.redirectUrl }],
26+
})) {
27+
existing.push(r);
28+
}
29+
if (existing.length > 0) {
30+
const context = this.getContext();
31+
if (context?.response) context.response.status = 409;
32+
return { error: 'Chain redirect detected' };
33+
}
34+
const id = Math.random().toString(36).slice(2);
35+
const user = this.getContext()?.user;
36+
const record = { id, ...body, createdBy: user?.username || 'anonymous' };
37+
// Write the redirect rule and audit record using static methods
38+
await tables.RedirectRule.put(record);
39+
await tables.RedirectChange.put({
40+
id: Math.random().toString(36).slice(2),
41+
redirectId: id,
42+
operation: 'create',
43+
previousState: null,
44+
});
45+
return record;
46+
}
47+
}
48+
49+
// Block external mutations on RedirectChange (audit log is immutable from outside)
50+
export class RedirectChange extends tables.RedirectChange {
51+
post() {
52+
return new Response(null, { status: 405 });
53+
}
54+
put() {
55+
return new Response(null, { status: 405 });
56+
}
57+
patch() {
58+
return new Response(null, { status: 405 });
59+
}
60+
delete() {
61+
return new Response(null, { status: 405 });
62+
}
63+
}
64+
65+
// RoutingDecision: POST-only routing lookup endpoint (Walmart USGM)
66+
export class RoutingDecision extends Resource {
67+
static loadAsInstance = false;
68+
async post(query, body) {
69+
const { path } = body;
70+
if (!path) return {};
71+
const now = new Date();
72+
const rules = [];
73+
for await (const r of tables.RedirectRule.search({ conditions: [{ attribute: 'matchUrl', value: path }] })) {
74+
rules.push(r);
75+
}
76+
const rule = rules.find((r) => {
77+
if (r.startTime && new Date(r.startTime) > now) return false;
78+
if (r.endTime && new Date(r.endTime) < now) return false;
79+
return true;
80+
});
81+
if (rule) return { shouldRedirect: true, status: rule.statusCode || 302, location: rule.redirectUrl };
82+
return {};
83+
}
84+
}
85+
86+
// AbuseCounter: atomic counter with 403 threshold (Ford PasswordResetAbuse pattern)
87+
export class AbuseCounter extends tables.AbuseCounter {
88+
async put(_body, _ctx) {
89+
// this is the loaded record instance; get current count from the stored record
90+
const current = await this.get();
91+
const id = this.getId();
92+
const newCount = ((current && current.count) || 0) + 1;
93+
if (newCount > 5) {
94+
const context = this.getContext();
95+
if (context?.response) context.response.status = 403;
96+
return { error: 'Too many attempts' };
97+
}
98+
// Full update via single-arg super.put (legacy: update(record, true) + save)
99+
await super.put({ id, count: newCount });
100+
return { count: newCount };
101+
}
102+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
type WorkItem @table @export {
2+
id: ID @primaryKey
3+
state: String @indexed
4+
payload: String
5+
result: String
6+
resultAt: Date @updatedTime
7+
}
8+
9+
type RedirectRule @table @export @sealed {
10+
id: ID @primaryKey
11+
matchUrl: String @indexed
12+
redirectUrl: String @indexed
13+
statusCode: Int
14+
startTime: Date
15+
endTime: Date
16+
createdBy: String
17+
}
18+
19+
type RedirectChange @table @export {
20+
id: ID @primaryKey
21+
redirectId: String @indexed
22+
operation: String
23+
previousState: String
24+
createdAt: Date @createdTime
25+
}
26+
27+
type AbuseCounter @table(expiration: 10) @export {
28+
id: ID @primaryKey
29+
count: Int
30+
}

integrationTests/apiTests/custom-resources.test.ts renamed to integrationTests/resources/custom-resources.test.ts

Lines changed: 13 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -8,156 +8,23 @@
88
* - Chain-redirect detection (409 on A→B when B→C would chain)
99
* - Abuse-counter threshold (Ford PasswordResetAbuse: 403 after N attempts)
1010
*
11-
* Self-contained: deploys an inline component with schema + resources JS and
12-
* restarts http_workers. Skipped on Windows (restart_service http_workers
13-
* crashes the Harper instance on Windows — see HarperFast/harper#549).
11+
* Component files live in integrationTests/fixtures/custom-resources/.
12+
* Skipped on Windows (restart_service http_workers crashes the Harper instance
13+
* on Windows — see HarperFast/harper#549).
1414
*
1515
* Implements HarperFast/harper#1190.
1616
*/
1717
import { suite, test, before, after } from 'node:test';
1818
import { strictEqual, ok, deepStrictEqual } from 'node:assert/strict';
19+
import { readFileSync } from 'node:fs';
20+
import { join, dirname } from 'node:path';
21+
import { fileURLToPath } from 'node:url';
1922
import { startHarper, teardownHarper } from '@harperfast/integration-testing';
20-
import { createApiClient } from './utils/client.mjs';
21-
import { installAppComponent } from './utils/components.mjs';
23+
import { createApiClient } from '../apiTests/utils/client.mjs';
24+
import { installAppComponent } from '../apiTests/utils/components.mjs';
2225

23-
// ---------------------------------------------------------------------------
24-
// Component definition
25-
// ---------------------------------------------------------------------------
26-
27-
const SCHEMA_GRAPHQL = `
28-
type WorkItem @table @export {
29-
id: ID @primaryKey
30-
state: String @indexed
31-
payload: String
32-
result: String
33-
resultAt: Date @updatedTime
34-
}
35-
36-
type RedirectRule @table @export @sealed {
37-
id: ID @primaryKey
38-
matchUrl: String @indexed
39-
redirectUrl: String @indexed
40-
statusCode: Int
41-
startTime: Date
42-
endTime: Date
43-
createdBy: String
44-
}
45-
46-
type RedirectChange @table @export {
47-
id: ID @primaryKey
48-
redirectId: String @indexed
49-
operation: String
50-
previousState: String
51-
createdAt: Date @createdTime
52-
}
53-
54-
type AbuseCounter @table(expiration: 10) @export {
55-
id: ID @primaryKey
56-
count: Int
57-
}
58-
`;
59-
60-
const RESOURCES_JS = `
61-
// WorkItem: async write-then-patch pattern (CDI RT enqueueing + AI inference result attachment)
62-
export class WorkItem extends tables.WorkItem {
63-
async post(body, ctx) {
64-
const id = Math.random().toString(36).slice(2);
65-
// Use static class method to create a new record by explicit id
66-
await tables.WorkItem.put({ id, state: 'pending', payload: JSON.stringify(body) });
67-
return { id, state: 'pending' };
68-
}
69-
async patch(body, ctx) {
70-
// this is the loaded record instance; doesExist() tells us if it was found
71-
if (!this.doesExist()) return new Response(null, { status: 404 });
72-
const current = await this.get();
73-
// Full update via single-arg super.put (legacy: update(record, true) + save)
74-
await super.put({ ...current, state: 'completed', result: body.result, resultAt: new Date() });
75-
return { state: 'completed' };
76-
}
77-
}
78-
79-
// RedirectRule: routing decision with audit trail (Walmart USGM pattern)
80-
export class RedirectRule extends tables.RedirectRule {
81-
async post(body, ctx) {
82-
// chain detection: check if redirectUrl matches any existing matchUrl
83-
const existing = [];
84-
for await (const r of tables.RedirectRule.search({ conditions: [{ attribute: 'matchUrl', value: body.redirectUrl }] })) {
85-
existing.push(r);
86-
}
87-
if (existing.length > 0) {
88-
const context = this.getContext();
89-
if (context?.response) context.response.status = 409;
90-
return { error: 'Chain redirect detected' };
91-
}
92-
const id = Math.random().toString(36).slice(2);
93-
const user = this.getContext()?.user;
94-
const record = { id, ...body, createdBy: user?.username || 'anonymous' };
95-
// Write the redirect rule and audit record using static methods
96-
await tables.RedirectRule.put(record);
97-
await tables.RedirectChange.put({
98-
id: Math.random().toString(36).slice(2),
99-
redirectId: id,
100-
operation: 'create',
101-
previousState: null,
102-
});
103-
return record;
104-
}
105-
}
106-
107-
// Block external mutations on RedirectChange (audit log is immutable from outside)
108-
export class RedirectChange extends tables.RedirectChange {
109-
post() { return new Response(null, { status: 405 }); }
110-
put() { return new Response(null, { status: 405 }); }
111-
patch() { return new Response(null, { status: 405 }); }
112-
delete() { return new Response(null, { status: 405 }); }
113-
}
114-
115-
// RoutingDecision: POST-only routing lookup endpoint (Walmart USGM)
116-
export class RoutingDecision extends Resource {
117-
static loadAsInstance = false;
118-
async post(query, body) {
119-
const { path } = body;
120-
if (!path) return {};
121-
const now = new Date();
122-
const rules = [];
123-
for await (const r of tables.RedirectRule.search({ conditions: [{ attribute: 'matchUrl', value: path }] })) {
124-
rules.push(r);
125-
}
126-
const rule = rules.find(r => {
127-
if (r.startTime && new Date(r.startTime) > now) return false;
128-
if (r.endTime && new Date(r.endTime) < now) return false;
129-
return true;
130-
});
131-
if (rule) return { shouldRedirect: true, status: rule.statusCode || 302, location: rule.redirectUrl };
132-
return {};
133-
}
134-
}
135-
136-
// AbuseCounter: atomic counter with 403 threshold (Ford PasswordResetAbuse pattern)
137-
export class AbuseCounter extends tables.AbuseCounter {
138-
async put(body, ctx) {
139-
// this is the loaded record instance; get current count from the stored record
140-
const current = await this.get();
141-
const id = this.getId();
142-
const newCount = ((current && current.count) || 0) + 1;
143-
if (newCount > 5) {
144-
const context = this.getContext();
145-
if (context?.response) context.response.status = 403;
146-
return { error: 'Too many attempts' };
147-
}
148-
// Full update via single-arg super.put (legacy: update(record, true) + save)
149-
await super.put({ id, count: newCount });
150-
return { count: newCount };
151-
}
152-
}
153-
`;
154-
155-
const CONFIG_YAML = `graphqlSchema:
156-
files: '*.graphql'
157-
jsResource:
158-
files: resources.js
159-
rest: true
160-
`;
26+
const __dirname = dirname(fileURLToPath(import.meta.url));
27+
const FIXTURE_DIR = join(__dirname, '../fixtures/custom-resources');
16128

16229
// ---------------------------------------------------------------------------
16330
// Helpers
@@ -191,9 +58,9 @@ suite('Custom resource patterns', { skip: skipSuite }, (ctx) => {
19158
await installAppComponent(client, {
19259
project: 'customResources',
19360
files: {
194-
'schema.graphql': SCHEMA_GRAPHQL,
195-
'resources.js': RESOURCES_JS,
196-
'config.yaml': CONFIG_YAML,
61+
'schema.graphql': readFileSync(join(FIXTURE_DIR, 'schema.graphql'), 'utf-8'),
62+
'resources.js': readFileSync(join(FIXTURE_DIR, 'resources.js'), 'utf-8'),
63+
'config.yaml': readFileSync(join(FIXTURE_DIR, 'config.yaml'), 'utf-8'),
19764
},
19865
probePath: '/WorkItem/',
19966
restartTimeoutMs: 120_000,

0 commit comments

Comments
 (0)