From f8141d5acfe52c9031d9dafff9a93962d914a01d Mon Sep 17 00:00:00 2001 From: Joel234-png Date: Mon, 19 Oct 2026 21:10:36 +0100 Subject: [PATCH] Add contract test, document cleanup_stream, and Storybook stories for components Closes #1051, #1052, #1053, #1054 - Add test_cleanup_stream_succeeds_while_paused to verify cleanup_stream works while paused since it only removes storage without modifying state (issue #1051) - Document in cleanup_stream doc comment that there is no require_not_paused guard (issue #1051) - Add NotificationBell Storybook stories covering no notifications, unread, and long list (issue #1052) - Add WebhookSettings Storybook stories covering empty state, registered webhook, and delivery history (issue #1053) - Add AddressBookSettings Storybook stories covering empty book and book with entries (issue #1054) --- .../layout/notification-bell.stories.tsx | 64 ++++++++++++ .../address-book-settings.stories.tsx | 69 +++++++++++++ .../webhooks/webhook-settings.stories.tsx | 98 +++++++++++++++++++ contracts/streaming/src/lib.rs | 4 + contracts/streaming/src/test.rs | 44 +++++++++ 5 files changed, 279 insertions(+) create mode 100644 components/layout/notification-bell.stories.tsx create mode 100644 components/settings/address-book-settings.stories.tsx create mode 100644 components/webhooks/webhook-settings.stories.tsx diff --git a/components/layout/notification-bell.stories.tsx b/components/layout/notification-bell.stories.tsx new file mode 100644 index 0000000..2c5be9c --- /dev/null +++ b/components/layout/notification-bell.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { NotificationBell } from './notification-bell' + +const meta = { + title: 'Layout/NotificationBell', + component: NotificationBell, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const NoNotifications: Story = { + beforeEach: () => { + localStorage.removeItem('notifications') + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const UnreadNotifications: Story = { + beforeEach: () => { + const notifications = [ + { id: '1', title: 'Stream Created', message: 'Your stream is active', read: false }, + { id: '2', title: 'Payment Received', message: '10 USDC received', read: true }, + ] + localStorage.setItem('notifications', JSON.stringify(notifications)) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const LongNotificationList: Story = { + beforeEach: () => { + const notifications = Array.from({ length: 15 }, (_, i) => ({ + id: `${i}`, + title: `Notification ${i + 1}`, + message: `This is notification number ${i + 1}`, + read: i > 5, + timestamp: new Date(Date.now() - i * 60000).toISOString(), + })) + localStorage.setItem('notifications', JSON.stringify(notifications)) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/components/settings/address-book-settings.stories.tsx b/components/settings/address-book-settings.stories.tsx new file mode 100644 index 0000000..dad9514 --- /dev/null +++ b/components/settings/address-book-settings.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { AddressBookSettings } from './address-book-settings' + +const meta = { + title: 'Settings/AddressBookSettings', + component: AddressBookSettings, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const EmptyBook: Story = { + beforeEach: () => { + localStorage.removeItem('address_book') + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const BookWithEntries: Story = { + beforeEach: () => { + const addressBook = [ + { + id: 'addr_1', + name: 'Alice Johnson', + address: 'GDZST3XVCDTUJ76ZAV2HA72KYUW4DKOWLQ4BVE32YQRGEAIFUQ2YCQY', + label: 'Primary Recipient', + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'addr_2', + name: 'Bob Smith', + address: 'GBQT74NWN7KHZK5VPVL2YQHQIRLSVS3ATVUQRXE2BXVMQUQMVKTM5HSQ', + label: 'Company Account', + createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'addr_3', + name: 'Carol Lee', + address: 'GAOK5WSI7U3DKPWYULJEZGMFS5ZCJQPGQXE5MGRFVE52QOBVVBRKQW23', + label: 'Vault Account', + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'addr_4', + name: 'David Chen', + address: 'GCSK5XT5ADFLBV7VJXVJTHZ3AKGFGAVDPVH3POYBNSPVWVVPNQVGJBQY', + createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + }, + ] + localStorage.setItem('address_book', JSON.stringify(addressBook)) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/components/webhooks/webhook-settings.stories.tsx b/components/webhooks/webhook-settings.stories.tsx new file mode 100644 index 0000000..4068bc9 --- /dev/null +++ b/components/webhooks/webhook-settings.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { WebhookSettings } from './webhook-settings' + +const meta = { + title: 'Settings/WebhookSettings', + component: WebhookSettings, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const EmptyState: Story = { + beforeEach: () => { + localStorage.removeItem('webhooks') + localStorage.removeItem('webhook_deliveries') + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const RegisteredWebhook: Story = { + beforeEach: () => { + const webhooks = [ + { + id: 'wh_1', + url: 'https://api.example.com/webhooks/flowstar', + events: ['stream.created', 'payment.received'], + active: true, + createdAt: new Date().toISOString(), + }, + ] + localStorage.setItem('webhooks', JSON.stringify(webhooks)) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const DeliveryHistory: Story = { + beforeEach: () => { + const webhooks = [ + { + id: 'wh_1', + url: 'https://api.example.com/webhooks/flowstar', + events: ['stream.created', 'payment.received'], + active: true, + createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + ] + const deliveries = [ + { + id: 'del_1', + webhookId: 'wh_1', + status: 'success', + statusCode: 200, + timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'del_2', + webhookId: 'wh_1', + status: 'failed', + statusCode: 500, + error: 'Internal Server Error', + timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + retries: 3, + }, + { + id: 'del_3', + webhookId: 'wh_1', + status: 'success', + statusCode: 200, + timestamp: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + }, + ] + localStorage.setItem('webhooks', JSON.stringify(webhooks)) + localStorage.setItem('webhook_deliveries', JSON.stringify(deliveries)) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} diff --git a/contracts/streaming/src/lib.rs b/contracts/streaming/src/lib.rs index 5d7bdeb..2197d17 100644 --- a/contracts/streaming/src/lib.rs +++ b/contracts/streaming/src/lib.rs @@ -1290,6 +1290,10 @@ impl StreamingContract { /// /// Either party (sender or recipient) may call this. The stream must be /// cancelled or fully drained before cleanup is allowed. + /// + /// Note: cleanup_stream has no require_not_paused guard and will succeed + /// even when the contract is paused, since cleanup only removes storage + /// and does not modify stream state (issue #1051). pub fn cleanup_stream(env: Env, caller: Address, stream_id: u64) -> Result<(), StreamError> { caller.require_auth(); diff --git a/contracts/streaming/src/test.rs b/contracts/streaming/src/test.rs index 3b7f4dd..79b1858 100644 --- a/contracts/streaming/src/test.rs +++ b/contracts/streaming/src/test.rs @@ -2149,3 +2149,47 @@ fn test_set_delegate_called_twice_overwrites_previous() { // Verify funds went to recipient (via second delegate) assert_eq!(t.token().balance(&t.recipient), withdrawable); } + +#[test] +fn test_cleanup_stream_succeeds_while_paused() { + // cleanup_stream has no require_not_paused guard and will succeed even when + // the contract is paused, since cleanup only removes storage without + // modifying stream state (issue #1051). + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + let client = t.client(); + let params = t.default_params(now); + let total = params.total_amount; + + t.token().approve( + &t.sender, + &t.contract_id, + &total, + &(t.env.ledger().sequence() + 500), + ); + let stream_id = client.create_stream(&t.sender, ¶ms); + + // Cancel the stream to make it eligible for cleanup + t.set_time(now + 500); + client.cancel(&stream_id); + + // Pause the contract + client.pause(); + + // cleanup_stream should still succeed while paused (no require_not_paused guard) + let result = client.try_cleanup_stream(&t.sender, &stream_id); + assert_eq!( + result, + Ok(Ok(())), + "cleanup_stream should succeed while paused since it only removes storage" + ); + + // Verify the stream is actually cleaned up + assert!( + !client + .get_archived_sent_streams(&t.sender, &0, &100) + .contains(stream_id), + "stream should be removed from archive after cleanup" + ); +}