feat(web): add Subject creation to Memory index
This commit is contained in:
@@ -35,6 +35,8 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
|
||||
|
||||
export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
|
||||
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import type {
|
||||
Diagnostic,
|
||||
DiagnosticSeverity,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
SubjektivResidentSurfaceResponse,
|
||||
SubjektivResidentSurfaceSnapshot,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
SubjektivSubjectState,
|
||||
@@ -81,6 +83,128 @@ export const MEMORY_API_LOAD_POLICY = {
|
||||
maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
|
||||
} as const;
|
||||
|
||||
const MAX_SUBJECT_ROLE_BYTES = 256;
|
||||
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
||||
|
||||
export type SubjektivSubjectCreateErrorKind =
|
||||
| "validation"
|
||||
| "rejected"
|
||||
| "unknown_outcome";
|
||||
|
||||
export class SubjektivSubjectCreateError extends Error {
|
||||
readonly kind: SubjektivSubjectCreateErrorKind;
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(
|
||||
kind: SubjektivSubjectCreateErrorKind,
|
||||
message: string,
|
||||
status: number | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SubjektivSubjectCreateError";
|
||||
this.kind = kind;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSubjektivSubjectCreateRequest(
|
||||
request: SubjektivSubjectCreateRequest,
|
||||
): SubjektivSubjectCreateRequest {
|
||||
if (request.role.trim().length === 0) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject role must not be empty.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
new TextEncoder().encode(request.role).byteLength >
|
||||
MAX_SUBJECT_ROLE_BYTES ||
|
||||
/\p{Cc}/u.test(request.role)
|
||||
) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject role must be at most 256 bytes and contain no control characters.",
|
||||
);
|
||||
}
|
||||
return { role: request.role };
|
||||
}
|
||||
|
||||
export async function createSubjektivSubject(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
request: SubjektivSubjectCreateRequest,
|
||||
): Promise<SubjektivSubjectResponse> {
|
||||
const validated = validateSubjektivSubjectCreateRequest(request);
|
||||
const path = workspaceApiPath(workspaceId, "/subjektiv/subjects");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(validated),
|
||||
});
|
||||
} catch {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The request outcome is unknown. A Subject may have been created.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await subjektivSubjectCreateErrorMessage(response);
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"rejected",
|
||||
message,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The request outcome is unknown. A Subject may have been created.",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const subject = parseSubjektivSubjectResponse(
|
||||
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||
);
|
||||
if (subject.role !== validated.role) {
|
||||
throw new Error("created Subject role does not match the request");
|
||||
}
|
||||
return subject;
|
||||
} catch {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The Subject may have been created, but the response could not be confirmed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function subjektivSubjectCreateErrorMessage(
|
||||
response: Response,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const payload = await readBoundedJson(
|
||||
response,
|
||||
MAX_SUBJECT_CREATE_ERROR_BYTES,
|
||||
);
|
||||
if (typeof payload === "object" && payload !== null) {
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.message === "string" && record.message.length > 0) {
|
||||
return record.message;
|
||||
}
|
||||
if (typeof record.error === "string" && record.error.length > 0) {
|
||||
return record.error;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A rejected request still has a known outcome even when its body is invalid.
|
||||
}
|
||||
return `Subject creation failed with HTTP ${response.status}.`;
|
||||
}
|
||||
|
||||
export function parseMemoryDocumentResponse(
|
||||
value: unknown,
|
||||
): MemoryDocumentResponse {
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
import {
|
||||
createSubjektivSubject,
|
||||
SubjektivSubjectCreateError,
|
||||
} from '#lib/workspace/memory/api.ts';
|
||||
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||
let createExpanded = $state(false);
|
||||
let role = $state('');
|
||||
let submitting = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
let roleInvalid = $state(false);
|
||||
let createOutcomeUnknown = $state(false);
|
||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||
let createButton = $state<HTMLButtonElement>();
|
||||
let roleInput = $state<HTMLInputElement>();
|
||||
|
||||
function subjectHref(subjectId: string): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||
@@ -14,6 +30,69 @@
|
||||
const path = workspaceRoute(data.workspaceId, '/memory');
|
||||
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||
}
|
||||
|
||||
async function openCreateForm(): Promise<void> {
|
||||
createExpanded = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
await tick();
|
||||
roleInput?.focus();
|
||||
}
|
||||
|
||||
async function cancelCreate(): Promise<void> {
|
||||
if (submitting) return;
|
||||
createExpanded = false;
|
||||
role = '';
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
await tick();
|
||||
createButton?.focus();
|
||||
}
|
||||
|
||||
async function submitCreate(): Promise<void> {
|
||||
if (submitting || createdSubject) return;
|
||||
submitting = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
try {
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
|
||||
createdSubject = subject;
|
||||
try {
|
||||
await goto(subjectHref(subject.id));
|
||||
} catch {
|
||||
createError = 'Subject created, but navigation failed. Open the created Subject below.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SubjektivSubjectCreateError) {
|
||||
roleInvalid = error.kind === 'validation';
|
||||
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
||||
createError = error.status === 401 || error.status === 403
|
||||
? 'You do not have permission to create Subjects in this Workspace.'
|
||||
: error.message;
|
||||
if (roleInvalid) {
|
||||
await tick();
|
||||
roleInput?.focus();
|
||||
}
|
||||
} else {
|
||||
createOutcomeUnknown = true;
|
||||
createError = 'The request outcome is unknown. A Subject may have been created.';
|
||||
}
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createFormKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && !submitting) {
|
||||
event.preventDefault();
|
||||
void cancelCreate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -26,13 +105,73 @@
|
||||
<div>
|
||||
<p class="memory-eyebrow">Memory</p>
|
||||
<h1 id="memory-subjects-heading">Subjects</h1>
|
||||
<p>Read-only durable context, organized by explicit subject.</p>
|
||||
<p>Durable context, organized by explicit subject.</p>
|
||||
</div>
|
||||
<div class="memory-header-actions">
|
||||
{#if data.subjects.data}
|
||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={createButton}
|
||||
type="button"
|
||||
class="subject-create-trigger"
|
||||
aria-expanded={createExpanded}
|
||||
aria-controls="subject-create-panel"
|
||||
onclick={() => void openCreateForm()}
|
||||
>New Subject</button>
|
||||
</div>
|
||||
{#if data.subjects.data}
|
||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if createExpanded}
|
||||
<form
|
||||
id="subject-create-panel"
|
||||
class="subject-create-panel"
|
||||
aria-labelledby="subject-create-heading"
|
||||
aria-busy={submitting}
|
||||
onsubmit={(event) => { event.preventDefault(); void submitCreate(); }}
|
||||
>
|
||||
<div class="subject-create-copy">
|
||||
<h2 id="subject-create-heading">Create Subject</h2>
|
||||
<p id="subject-role-help">Role describes the Subject’s durable identity. The service assigns its ID; creating it does not start or attach a Worker.</p>
|
||||
</div>
|
||||
<label class="subject-role-field">
|
||||
<span>Role</span>
|
||||
<input
|
||||
bind:this={roleInput}
|
||||
bind:value={role}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
disabled={submitting || createdSubject !== null}
|
||||
onkeydown={createFormKeydown}
|
||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
||||
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||
/>
|
||||
</label>
|
||||
{#if createError}
|
||||
<div
|
||||
id="subject-create-error"
|
||||
class:unknown-outcome={createOutcomeUnknown}
|
||||
class="subject-create-error"
|
||||
role="alert"
|
||||
>
|
||||
<p>{createError}</p>
|
||||
{#if createOutcomeUnknown}
|
||||
<p><a href={pageHref()}>Check the first Subjects page before retrying.</a></p>
|
||||
{:else if createdSubject}
|
||||
<p><a href={subjectHref(createdSubject.id)}>Open {createdSubject.id}</a></p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="subject-create-actions">
|
||||
<button type="submit" disabled={submitting || createdSubject !== null}>
|
||||
{submitting ? 'Creating…' : 'Create Subject'}
|
||||
</button>
|
||||
<button type="button" class="subject-create-cancel" disabled={submitting} onclick={() => void cancelCreate()}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.subjects.data}
|
||||
{#if subjects.length === 0}
|
||||
<div class="memory-state" role="status" data-memory-state="empty">
|
||||
@@ -127,6 +266,111 @@
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-header-actions,
|
||||
.subject-create-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.subject-create-trigger,
|
||||
.subject-create-actions button {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-soft);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subject-create-trigger:hover,
|
||||
.subject-create-trigger:focus-visible,
|
||||
.subject-create-actions button:hover:not(:disabled),
|
||||
.subject-create-actions button:focus-visible:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.subject-create-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.subject-create-actions .subject-create-cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.subject-create-actions {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.subject-create-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-soft);
|
||||
}
|
||||
|
||||
.subject-create-copy h2,
|
||||
.subject-create-copy p,
|
||||
.subject-create-error p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subject-create-copy p,
|
||||
.subject-create-error {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-role-field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subject-role-field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-soft);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.subject-role-field input[aria-invalid='true'] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.subject-create-error {
|
||||
grid-column: 2 / -1;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.subject-create-error a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.subject-create-error.unknown-outcome {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.subject-list {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -231,6 +475,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-create-panel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subject-create-error,
|
||||
.subject-create-actions {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.subject-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
@@ -243,6 +498,14 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.subject-create-panel {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.subject-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
const { gotoMock } = vi.hoisted(() => ({
|
||||
gotoMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: gotoMock }));
|
||||
|
||||
import SubjectIndexPage from "./+page.svelte";
|
||||
import SubjectPage from "./[subjectId]/+page.svelte";
|
||||
import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const result = <T>(data: T | null, error: string | null = null) => ({
|
||||
data,
|
||||
@@ -24,6 +41,18 @@ function subject() {
|
||||
};
|
||||
}
|
||||
|
||||
function subjectIndexData(items = [subject()]) {
|
||||
return {
|
||||
workspaceId: "workspace-1",
|
||||
cursor: null,
|
||||
subjects: result({
|
||||
limit: 100,
|
||||
items,
|
||||
has_more: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("renders the subject index as the Memory product entry", () => {
|
||||
render(SubjectIndexPage, {
|
||||
data: {
|
||||
@@ -40,6 +69,7 @@ test("renders the subject index as the Memory product entry", () => {
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Subjects", level: 1 })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "New Subject" })).not.toBeNull();
|
||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||
@@ -50,6 +80,134 @@ test("renders the subject index as the Memory product entry", () => {
|
||||
expect(screen.queryByText("Staging")).toBeNull();
|
||||
});
|
||||
|
||||
test("creates one Subject with the exact role and navigates to its generated detail", async () => {
|
||||
let resolveRequest!: (response: Response) => void;
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn(() => response);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectIndexPage, { data: subjectIndexData() } as never);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "New Subject" });
|
||||
await fireEvent.click(trigger);
|
||||
const roleInput = screen.getByRole("textbox", {
|
||||
name: "Role",
|
||||
}) as HTMLInputElement;
|
||||
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
||||
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
|
||||
|
||||
const submit = screen.getByRole("button", { name: "Create Subject" });
|
||||
await fireEvent.click(submit);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Creating…" }).hasAttribute("disabled"),
|
||||
)
|
||||
.toBe(true);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Creating…" }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRequest(Response.json({
|
||||
id: "generated-subject-1",
|
||||
role: " Review lead ",
|
||||
state: "active",
|
||||
store_revision: 0,
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
updated_at: "2026-01-03T00:00:00Z",
|
||||
}, { status: 201 }));
|
||||
await waitFor(() => {
|
||||
expect(gotoMock).toHaveBeenCalledWith(
|
||||
"/w/workspace-1/memory/generated-subject-1",
|
||||
);
|
||||
});
|
||||
|
||||
const [path, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe('{"role":" Review lead "}');
|
||||
});
|
||||
|
||||
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
render(SubjectIndexPage, { data: subjectIndexData([]) } as never);
|
||||
|
||||
expect(screen.getByText("No Memory subjects.")).not.toBeNull();
|
||||
const trigger = screen.getByRole("button", { name: "New Subject" });
|
||||
await fireEvent.click(trigger);
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Role",
|
||||
}) as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "Draft role" } });
|
||||
await fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves invalid and rejected role drafts with accessible errors", async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve(Response.json(
|
||||
{
|
||||
error: "Forbidden",
|
||||
message: "workspace permission denied",
|
||||
diagnostics: [],
|
||||
},
|
||||
{ status: 403 },
|
||||
))
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectIndexPage, { data: subjectIndexData() } as never);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "New Subject" }));
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Role",
|
||||
}) as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: " " } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain("must not be empty");
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(input.value).toBe(" ");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await fireEvent.input(input, { target: { value: "Release reviewer" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"do not have permission",
|
||||
);
|
||||
});
|
||||
expect(input.value).toBe("Release reviewer");
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("warns about an unknown create outcome without replaying the POST", async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.reject(new TypeError("connection reset"))
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectIndexPage, { data: subjectIndexData() } as never);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "New Subject" }));
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Role",
|
||||
}) as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "Incident coordinator" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"may have been created",
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("link", { name: /Check the first Subjects page/ }))
|
||||
.not.toBeNull();
|
||||
expect(input.value).toBe("Incident coordinator");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(gotoMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
@@ -62,7 +220,8 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
availability: "ready" as const,
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-1",
|
||||
body_md: "# Resident context\n\nUse the **current** committed record.",
|
||||
body_md:
|
||||
"# Resident context\n\nUse the **current** committed record.",
|
||||
memory_refs: [{ memory_id: "memory-1", revision: 3 }],
|
||||
built_from_store_revision: 9,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
|
||||
@@ -3,6 +3,7 @@ declare const Deno: {
|
||||
};
|
||||
|
||||
import {
|
||||
createSubjektivSubject,
|
||||
parseMemoryStagingListResponse,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
parseSubjektivSubjectListResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
SubjektivSubjectCreateError,
|
||||
validateSubjektivSubjectCreateRequest,
|
||||
} from "../src/lib/workspace/memory/api.ts";
|
||||
|
||||
function stable(value: unknown): unknown {
|
||||
@@ -44,6 +47,30 @@ function assertThrows(fn: () => void, expectedMessage: string): void {
|
||||
throw new Error(`expected function to throw ${expectedMessage}`);
|
||||
}
|
||||
|
||||
async function assertSubjectCreateRejects(
|
||||
fn: () => Promise<unknown>,
|
||||
expectedKind: SubjektivSubjectCreateError["kind"],
|
||||
expectedMessage: string,
|
||||
expectedStatus: number | null = null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (!(error instanceof SubjektivSubjectCreateError)) throw error;
|
||||
assertEquals(error.kind, expectedKind);
|
||||
assertEquals(error.status, expectedStatus);
|
||||
if (!error.message.includes(expectedMessage)) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(error.message)} to include ${
|
||||
JSON.stringify(expectedMessage)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected Subject creation to reject with ${expectedKind}`);
|
||||
}
|
||||
|
||||
function subject(id = "subject-1") {
|
||||
return {
|
||||
id,
|
||||
@@ -198,6 +225,117 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||
assertEquals(
|
||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
||||
{ role: " Release coordinator " },
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
||||
"must not be empty",
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: "line\nbreak" }),
|
||||
"control characters",
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
||||
"at most 256 bytes",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
|
||||
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
||||
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
||||
requests.push({ path: String(path), init });
|
||||
return Response.json({
|
||||
...subject("subject-created"),
|
||||
role: " Release coordinator ",
|
||||
}, { status: 201 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||
role: " Release coordinator ",
|
||||
});
|
||||
assertEquals(created, {
|
||||
...subject("subject-created"),
|
||||
role: " Release coordinator ",
|
||||
});
|
||||
assertEquals(requests.length, 1);
|
||||
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
||||
assertEquals(requests[0].init?.method, "POST");
|
||||
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}');
|
||||
});
|
||||
|
||||
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
|
||||
let rejectedCalls = 0;
|
||||
const rejectedFetch = (async () => {
|
||||
rejectedCalls += 1;
|
||||
return Response.json(
|
||||
{
|
||||
error: "Forbidden",
|
||||
message: "workspace permission denied",
|
||||
diagnostics: [],
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(rejectedFetch, "workspace-1", {
|
||||
role: "Reviewer",
|
||||
}),
|
||||
"rejected",
|
||||
"workspace permission denied",
|
||||
403,
|
||||
);
|
||||
assertEquals(rejectedCalls, 1);
|
||||
|
||||
let serverErrorCalls = 0;
|
||||
const serverErrorFetch = (async () => {
|
||||
serverErrorCalls += 1;
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(serverErrorFetch, "workspace-1", {
|
||||
role: "Reviewer",
|
||||
}),
|
||||
"unknown_outcome",
|
||||
"may have been created",
|
||||
500,
|
||||
);
|
||||
assertEquals(serverErrorCalls, 1);
|
||||
|
||||
let mismatchCalls = 0;
|
||||
const mismatchFetch = (async () => {
|
||||
mismatchCalls += 1;
|
||||
return Response.json(subject("subject-created"), { status: 201 });
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(mismatchFetch, "workspace-1", {
|
||||
role: "Reviewer",
|
||||
}),
|
||||
"unknown_outcome",
|
||||
"could not be confirmed",
|
||||
);
|
||||
assertEquals(mismatchCalls, 1);
|
||||
|
||||
let unknownCalls = 0;
|
||||
const unknownFetch = (async () => {
|
||||
unknownCalls += 1;
|
||||
throw new TypeError("connection reset");
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||
"unknown_outcome",
|
||||
"may have been created",
|
||||
);
|
||||
assertEquals(unknownCalls, 1);
|
||||
});
|
||||
|
||||
Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => {
|
||||
const readyEmpty = {
|
||||
subject_id: "subject-1",
|
||||
|
||||
Reference in New Issue
Block a user