Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions tsworkspace/webapps/studio/server/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,9 +1093,11 @@ app.get(
handler(() => call("listEntityKinds", {})),
);

// Validate every EventModel in a project or domain in one call. Query:
// Validate every EventModel in a project, domain or namespace in one call.
// Query, exactly one scope:
// ?projectNamespace=foo&projectSlug=foo -- scope by Project
// ?domainNamespace=foo&domainSlug=foo -- scope by Domain
// ?namespace=foo -- scope by namespace
app.get(
"/api/validate-project",
branchHandler(async (req) => {
Expand All @@ -1108,6 +1110,7 @@ app.get(
"domainNamespace",
"domainSlug",
"domainVersion",
"namespace",
]) {
if (q[key] != null) {
const val = String(q[key]);
Expand All @@ -1129,12 +1132,16 @@ app.get(
const domainId = q.domainNamespace && q.domainSlug
? { namespace: String(q.domainNamespace), slug: String(q.domainSlug), version: String(q.domainVersion ?? "1") }
: null;
if (!projectId && !domainId) {
const err = /** @type {HttpError} */ (new Error("set projectNamespace+projectSlug OR domainNamespace+domainSlug"));
const namespaceScope = q.namespace ? String(q.namespace) : null;
const scopes = [projectId, domainId, namespaceScope].filter(Boolean).length;
if (scopes !== 1) {
const err = /** @type {HttpError} */ (new Error(
"set exactly one of projectNamespace+projectSlug, domainNamespace+domainSlug, or namespace",
));
err.status = 400;
throw err;
}
const args = projectId ? { projectId } : { domainId };
const args = projectId ? { projectId } : domainId ? { domainId } : { namespace: namespaceScope };
return await call("validateProject", args);
}),
);
Expand Down Expand Up @@ -1711,6 +1718,52 @@ app.get(
error: maskError(err),
});
}
// Domains and Subdomains declared in this namespace, even when they are
// not on the realizes chain: a Domain declared in a context's namespace
// is the subject of a finding, and a finding on an entity that is not
// loaded cannot link anywhere. Then the Project each loaded Domain names.
try {
/** @param {unknown} e */
const slugKey = (e) => envelopeKey(e)?.replace(/@[^@]*$/, "");
const loaded = new Set(knowledge.map(slugKey));
const declared = await paginateList((pageToken) => ({
namespaces: [ns],
kinds: [17, 18], // DOMAIN, SUBDOMAIN
latestVersionsOnly: true,
pageSize: 500,
pageToken,
}));
for (const ent of declared) {
const key = slugKey(ent);
if (key && loaded.has(key)) continue;
if (key) loaded.add(key);
knowledge.push(ent);
}
/** @type {Map<string, unknown>} */
const projectRefs = new Map();
for (const ent of knowledge) {
const pid = /** @type {any} */ (ent)?.domain?.project?.id;
if (pid?.namespace && pid?.slug) {
projectRefs.set(`${pid.namespace}/${pid.slug}`, { ...pid, version: pid.version || "1" });
}
}
const projects = await limitedMap(
[...projectRefs.values()],
GRPC_FANOUT_CONCURRENCY,
async (/** @type {unknown} */ id) => {
try {
const r = /** @type {any} */ (await call("getEntity", { kind: 20, id })); // PROJECT
return r.entity;
} catch (err) {
logEvent("warn", "fanout_project_fetch_failed", { kind: 20, id: /** @type {any} */ (id), error: maskError(err) });
return null;
}
},
);
for (const p of projects) if (p?.project) knowledge.push(p);
} catch (err) {
logEvent("warn", "namespace_strategic_fetch_failed", { error: maskError(err) });
}
return {
entities: decodeAnnotations([
...entities,
Expand Down
148 changes: 147 additions & 1 deletion tsworkspace/webapps/studio/server/index.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import request from 'supertest';

const here = path.dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -532,6 +532,55 @@ describe('GET /api/validate-project', () => {
const res = await request(app).get('/api/validate-project?projectNamespace=../bad&projectSlug=my-project');
expect(res.status).toBe(400);
});

describe('namespace scope', () => {
let seen;
let original;

beforeEach(() => {
original = fakeClient.validateProject;
seen = undefined;
fakeClient.validateProject = (req, _meta, _opts, cb) => {
seen = req;
cb(null, { reports: [], totalErrors: 0, totalWarnings: 0, totalInfo: 0 });
};
});

afterEach(() => {
fakeClient.validateProject = original;
});

it('maps ?namespace= to the namespace scope', async () => {
const res = await request(app).get('/api/validate-project?namespace=accounts');
expect(res.status).toBe(200);
expect(seen).toEqual({ namespace: 'accounts' });
});

it('still maps project and domain scopes to their ids', async () => {
await request(app).get('/api/validate-project?projectNamespace=acme&projectSlug=acme');
expect(seen).toEqual({ projectId: { namespace: 'acme', slug: 'acme', version: '1' } });
await request(app).get('/api/validate-project?domainNamespace=shop&domainSlug=shop&domainVersion=2');
expect(seen).toEqual({ domainId: { namespace: 'shop', slug: 'shop', version: '2' } });
});

it('returns 400 when the namespace is unsafe or too long', async () => {
expect((await request(app).get('/api/validate-project?namespace=../bad')).status).toBe(400);
expect((await request(app).get(`/api/validate-project?namespace=${'a'.repeat(5000)}`)).status).toBe(400);
expect(seen).toBeUndefined();
});

it('returns 400 unless exactly one scope is set', async () => {
const both = await request(app).get(
'/api/validate-project?namespace=accounts&projectNamespace=acme&projectSlug=acme',
);
expect(both.status).toBe(400);
expect(both.body.error).toMatch(/exactly one/);
expect(seen).toBeUndefined();
const none = await request(app).get('/api/validate-project');
expect(none.status).toBe(400);
expect(none.body.error).toMatch(/exactly one/);
});
});
});

describe('GET /api/event-model/:namespace/:slug/manifests', () => {
Expand Down Expand Up @@ -652,6 +701,103 @@ describe('GET /api/event-model/:namespace/:slug success path (members, halo, kno
expect(res.body.entities.some((e) => e.domain)).toBe(true);
});

it('loads the domains and subdomains declared in the namespace, and the project a domain names', async () => {
const originalGetEntity = fakeClient.getEntity;
const originalListEntities = fakeClient.listEntities;
const listed = [];
fakeClient.getEntity = (req, _meta, _opts, cb) => {
const { kind, id } = req;
if (kind === 12) {
cb(null, { entity: { eventModel: { id, title: 'Stray model', members: [] } } });
return;
}
if (kind === 16) {
cb(null, {
entity: {
boundedContext: {
id: { namespace: 'my-ns', slug: 'my-ns', version: '1' },
title: 'My context',
realizes: [{ id: { namespace: 'problem', slug: 'billing', version: '1' } }],
},
},
});
return;
}
if (kind === 18 && id.slug === 'billing') {
cb(null, {
entity: {
subdomain: {
id,
title: 'Billing',
domain: { id: { namespace: 'problem', slug: 'problem', version: '1' } },
},
},
});
return;
}
if (kind === 17 && id.namespace === 'problem') {
cb(null, { entity: { domain: { id, title: 'Problem' } } });
return;
}
if (kind === 20) {
cb(null, { entity: { project: { id, title: 'Acme' } } });
return;
}
cb(null, { entity: null });
};
fakeClient.listEntities = (req, _meta, _opts, cb) => {
listed.push(req);
const strayDomain = {
domain: {
id: { namespace: 'my-ns', slug: 'my-ns', version: '1' },
title: 'Stray domain',
project: { id: { namespace: 'acme', slug: 'acme', version: '1' } },
},
};
const parent = {
domain: { id: { namespace: 'problem', slug: 'problem', version: '2' }, title: 'Problem again' },
};
cb(null, { entities: req.namespaces?.[0] === 'my-ns' ? [strayDomain, parent] : [], nextPageToken: '' });
};

const res = await request(app).get('/api/event-model/my-ns/stray-model');

fakeClient.getEntity = originalGetEntity;
fakeClient.listEntities = originalListEntities;

expect(res.status).toBe(200);
expect(listed.some((r) => r.namespaces?.[0] === 'my-ns' && r.kinds?.join() === '17,18')).toBe(true);
const domains = res.body.entities.filter((e) => e.domain).map((e) => e.domain.title);
expect(domains.sort()).toEqual(['Problem', 'Stray domain']);
expect(res.body.entities.some((e) => e.project?.id?.slug === 'acme')).toBe(true);
});

it('keeps the rest of the knowledge graph when listing the namespace fails', async () => {
const originalGetEntity = fakeClient.getEntity;
const originalListEntities = fakeClient.listEntities;
fakeClient.getEntity = (req, _meta, _opts, cb) => {
const { kind, id } = req;
if (kind === 12) {
cb(null, { entity: { eventModel: { id, title: 'Model', members: [] } } });
return;
}
if (kind === 16) {
cb(null, { entity: { boundedContext: { id, title: 'Context' } } });
return;
}
cb(null, { entity: null });
};
fakeClient.listEntities = (_req, _meta, _opts, cb) => cb(new Error('listing exploded'));

const res = await request(app).get('/api/event-model/my-ns/list-fails');

fakeClient.getEntity = originalGetEntity;
fakeClient.listEntities = originalListEntities;

expect(res.status).toBe(200);
expect(res.body.entities.map((e) => Object.keys(e)[0]).sort()).toEqual(['boundedContext', 'eventModel']);
});

it('tolerates a member fetch failure by dropping that member rather than failing the request', async () => {
const originalGetEntity = fakeClient.getEntity;
fakeClient.getEntity = (req, _meta, _opts, cb) => {
Expand Down
96 changes: 89 additions & 7 deletions tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@ vi.mock('@/lib/api', () => ({
overview: vi.fn(),
search: vi.fn(),
namespaceRegistry: vi.fn(),
validateProject: vi.fn(),
},
}));

vi.mock('@/components/canvas/DomainChartBoard', () => ({
DomainChartBoard: ({ onSelect }: { onSelect: (key: string) => void }) => (
<button type="button" data-testid="domain-chart-board" onClick={() => onSelect('domain:market/trading@1')}>
chart
</button>
),
}));
vi.mock('@/components/canvas/DomainChartBoard', async () => {
const { useIssues } = await import('@/components/IssuesProvider');
return {
DomainChartBoard: ({ onSelect }: { onSelect: (key: string) => void }) => (
<button
type="button"
data-testid="domain-chart-board"
data-findings={useIssues()
.all.map((i) => i.code)
.join(',')}
onClick={() => onSelect('domain:market/trading@1')}
>
chart
</button>
),
};
});
vi.mock('@/components/canvas/ContextMapBoard', () => ({
ContextMapBoard: () => <div data-testid="context-map-board" />,
}));
Expand Down Expand Up @@ -73,6 +84,7 @@ describe('OverviewPage', () => {
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue(EMPTY_OVERVIEW);
(api.search as ReturnType<typeof vi.fn>).mockResolvedValue({ results: [] });
(api.namespaceRegistry as ReturnType<typeof vi.fn>).mockResolvedValue({ namespaces: [] });
(api.validateProject as ReturnType<typeof vi.fn>).mockResolvedValue({ reports: [] });
});

afterEach(() => {
Expand Down Expand Up @@ -417,4 +429,74 @@ describe('OverviewPage', () => {
fireEvent.click(screen.getByTestId('domain-chart-board'));
expect(screen.getByText('The trading charter.')).toBeDefined();
});

describe('domain chart findings', () => {
const PROJECT_OVERVIEW = {
entities: [
{
domain: {
id: { namespace: 'market', slug: 'trading', version: 1 },
title: 'Trading',
project: { id: { namespace: 'acme', slug: 'acme', version: 1 } },
},
},
{ domain: { id: { namespace: 'market', slug: 'risk', version: 1 }, title: 'Risk' } },
],
};
const STRATEGIC_REPORT = {
reports: [
{
issues: [
{
severity: 'SEVERITY_WARNING',
code: 'DOMAIN_IN_CONTEXT_NAMESPACE',
message: 'misplaced',
subject: { kind: 'ENTITY_KIND_DOMAIN', id: { namespace: 'market', slug: 'trading', version: '1' } },
},
],
},
],
};

async function openChart() {
render(<OverviewPage />);
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
});
fireEvent.click(screen.getByRole('tab', { name: 'Domain chart' }));
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
});
}

it('validates each project once, each domain without a project by itself and by namespace, on the current branch', async () => {
window.history.replaceState(null, '', '/?branch=feature-x');
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue(PROJECT_OVERVIEW);
await openChart();

const calls = (api.validateProject as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.map(([scope]) => scope)).toEqual([
{ projectNamespace: 'acme', projectSlug: 'acme', projectVersion: '1' },
{ domainNamespace: 'market', domainSlug: 'risk', domainVersion: '1' },
{ namespace: 'market' },
]);
for (const [, opts] of calls) expect(opts).toEqual(expect.objectContaining({ branch: 'feature-x' }));
});

it('hands the server findings to the chart', async () => {
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue(PROJECT_OVERVIEW);
(api.validateProject as ReturnType<typeof vi.fn>).mockResolvedValue(STRATEGIC_REPORT);
await openChart();
expect(screen.getByTestId('domain-chart-board').getAttribute('data-findings')).toBe(
'DOMAIN_IN_CONTEXT_NAMESPACE',
);
});

it('renders the chart without findings when validation fails', async () => {
(api.overview as ReturnType<typeof vi.fn>).mockResolvedValue(PROJECT_OVERVIEW);
(api.validateProject as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('unavailable'));
await openChart();
expect(screen.getByTestId('domain-chart-board').getAttribute('data-findings')).toBe('');
});
});
});
Loading
Loading