Skip to content

Commit f9d769d

Browse files
Merge pull request #1098 from smalruby/feature/admin-shared-starter
feat(admin): みんなの課題詳細にスタータープロジェクトの DL を表示
2 parents 754769f + 7e397b1 commit f9d769d

4 files changed

Lines changed: 84 additions & 2 deletions

File tree

infra/smalruby-admin/lambda/handler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,19 @@ async function handleGetSharedAssignment(
328328
)
329329
: null,
330330
})));
331+
// The starter project is part of what gets moderated — let the operator
332+
// download and inspect the actual .sb3.
333+
const starterUrl = content.starterKey
334+
? await getSignedUrl(
335+
s3Client,
336+
new GetObjectCommand({ Bucket: SHARED_BUCKET, Key: content.starterKey }),
337+
{ expiresIn: PRESIGNED_URL_DOWNLOAD_EXPIRY },
338+
)
339+
: null;
331340

332341
return {
333342
statusCode: 200,
334-
body: JSON.stringify({ ...mapSharedItemForAdmin(item), pages }),
343+
body: JSON.stringify({ ...mapSharedItemForAdmin(item), pages, starterUrl }),
335344
};
336345
}
337346

infra/smalruby-admin/lambda/tests/handler.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,44 @@ describe('みんなの課題 management (issue #1083)', () => {
284284
expect(queue[0].item.title).toBe('ねこあつめ入門');
285285
});
286286

287+
test('GET detail presigns page images and the starter project', async () => {
288+
mockSend.mockImplementation(async (command: {
289+
constructor: { name: string }; input?: { TableName?: string };
290+
}) => {
291+
const name = command.constructor.name;
292+
if (name === 'GetCommand' && command.input?.TableName?.includes('Admins')) return adminRow;
293+
if (name === 'GetCommand') return { Item: sharedItem };
294+
return {};
295+
});
296+
297+
const res = await handler({
298+
requestContext: { http: { method: 'GET', path: '/admin/shared-assignments/s1' } },
299+
pathParameters: { sharedId: 's1' },
300+
headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' },
301+
});
302+
expect(res.statusCode).toBe(200);
303+
const body = JSON.parse(res.body as string);
304+
expect(body.pages[0].imageUrl).toBe('https://signed.example/get');
305+
// The starter is moderated content too — the operator gets a download URL.
306+
expect(body.starterUrl).toBe('https://signed.example/get');
307+
308+
// Without a starterKey the field is null, not a broken link.
309+
mockSend.mockImplementation(async (command: {
310+
constructor: { name: string }; input?: { TableName?: string };
311+
}) => {
312+
const name = command.constructor.name;
313+
if (name === 'GetCommand' && command.input?.TableName?.includes('Admins')) return adminRow;
314+
if (name === 'GetCommand') return { Item: { ...sharedItem, content: { pages: [] } } };
315+
return {};
316+
});
317+
const bare = await handler({
318+
requestContext: { http: { method: 'GET', path: '/admin/shared-assignments/s1' } },
319+
pathParameters: { sharedId: 's1' },
320+
headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'https://smalruby.app' },
321+
});
322+
expect(JSON.parse(bare.body as string).starterUrl).toBeNull();
323+
});
324+
287325
test('PATCH flips the status and audits the action', async () => {
288326
const logs: string[] = [];
289327
const spy = jest.spyOn(console, 'log').mockImplementation((line: string) => {

packages/admin/src/components/shared-assignments-view.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ const SharedDetail = ({sharedId, onBack, onChanged}) => {
9494
>{detail.supplementUrl}</a>
9595
</p>
9696
) : null}
97+
<p
98+
className="admin-meta"
99+
data-testid="shared-admin-starter"
100+
>
101+
{detail.starterUrl ? (
102+
<a
103+
data-testid="shared-admin-starter-download"
104+
href={detail.starterUrl}
105+
rel="noopener noreferrer"
106+
target="_blank"
107+
>{'スタータープロジェクト (.sb3) をダウンロード'}</a>
108+
) : (
109+
'スタータープロジェクトなし'
110+
)}
111+
</p>
97112
{(detail.pages || []).map((page, index) => (
98113
<div
99114
className="admin-page"

packages/admin/test/unit/shared-assignments-view.test.jsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ const detail = {
3232
tags: ['甲子園'],
3333
supplementUrl: 'https://docs.google.com/x',
3434
reuseCount: 4,
35-
pages: [{text: 'ページ1', imageUrl: null}]
35+
pages: [{text: 'ページ1', imageUrl: null}],
36+
starterUrl: 'https://signed.example/starter.sb3'
3637
};
3738

3839
describe('SharedAssignmentsView (issue #1083)', () => {
@@ -77,6 +78,25 @@ describe('SharedAssignmentsView (issue #1083)', () => {
7778
await waitFor(() => expect(mockFetchReports.mock.calls.length).toBeGreaterThan(1));
7879
});
7980

81+
test('the detail offers the starter download, or says there is none', async () => {
82+
const first = render(<SharedAssignmentsView />);
83+
await waitFor(() => screen.getByTestId('shared-admin-queue-item-s1'));
84+
fireEvent.click(screen.getByTestId('shared-admin-queue-item-s1'));
85+
await waitFor(() => screen.getByTestId('shared-admin-detail'));
86+
expect(screen.getByTestId('shared-admin-starter-download')).toHaveAttribute(
87+
'href', 'https://signed.example/starter.sb3');
88+
first.unmount();
89+
90+
// Without a starter: an explicit "none" note, no dead link.
91+
mockFetchDetail.mockResolvedValue({...detail, starterUrl: null});
92+
render(<SharedAssignmentsView />);
93+
await waitFor(() => screen.getByTestId('shared-admin-queue-item-s1'));
94+
fireEvent.click(screen.getByTestId('shared-admin-queue-item-s1'));
95+
await waitFor(() => expect(screen.getByTestId('shared-admin-starter').textContent)
96+
.toContain('スタータープロジェクトなし'));
97+
expect(screen.queryByTestId('shared-admin-starter-download')).not.toBeInTheDocument();
98+
});
99+
80100
test('the confirmation can be cancelled without any API call', async () => {
81101
render(<SharedAssignmentsView />);
82102
await waitFor(() => screen.getByTestId('shared-admin-queue-item-s1'));

0 commit comments

Comments
 (0)