diff --git a/examples/javascript-client/README.md b/examples/javascript-client/README.md index 3681dae..787653a 100644 --- a/examples/javascript-client/README.md +++ b/examples/javascript-client/README.md @@ -98,6 +98,7 @@ You automatically become the **host** of the created group. - Click "Dissolve Group" to exit and dissolve the group (host only) - Only the group host can dissolve groups - Dissolving removes all members and deletes the group +- **Group Dissolution Detection**: When a host dissolves a group, all member nodes automatically detect the dissolution via WebSocket subscription and are disconnected from the group ### 3. Sensor Data @@ -178,10 +179,10 @@ await client.fireEventByNode(nodeId, groupId, domain, eventName, payload) // Queries await client.listGroupsByDomain(domain) -// Subscriptions (placeholder - polling for prototype) +// Subscriptions (WebSocket via AppSync) client.subscribeToDataUpdates(groupId, domain, callback) client.subscribeToEvents(groupId, domain, callback) -client.subscribeToGroupDissolve(groupId, domain, callback) +client.subscribeToGroupDissolve(groupId, domain, callback) // Real-time group dissolution detection ``` #### RateLimiter (mesh-client.js) @@ -228,7 +229,8 @@ if (detector.hasChanged('temperature', 25)) { - Connect with same API credentials - Click "Refresh Group List" - Join "Test Group" - - Observe events (when subscriptions implemented) + - Observe sensor data and events from Window 1 in real-time + - **Test Dissolution Detection**: When Window 1 dissolves the group, Window 2 should automatically detect and show error message ### Test Scenarios @@ -245,6 +247,8 @@ if (detector.hasChanged('temperature', 25)) { - [ ] Dissolve button only enabled when user is host - [ ] Dissolve group removes group from list - [ ] Host/Member role displays correctly +- [ ] Member nodes automatically detect and exit when host dissolves group +- [ ] Dissolution notification displays correct error message #### Sensor Data - [ ] Slider changes update display values @@ -267,29 +271,29 @@ if (detector.hasChanged('temperature', 25)) { ## Known Limitations -### Prototype Phase 1 +### Current Implementation Status -This is **Phase 1** of the prototype. The following features are placeholders: +The prototype has the following implementation status: -1. **WebSocket Subscriptions** - - Currently uses polling placeholders - - Will be implemented in Phase 2 - - `subscribeToDataUpdates()` logs but doesn't actually subscribe - - `subscribeToEvents()` logs but doesn't actually subscribe +1. **Implemented WebSocket Subscriptions** + - ✅ `subscribeToGroupDissolve()` - Real-time group dissolution detection (Phase 2-4) + - ✅ `subscribeToDataUpdates()` - Real-time sensor data updates (Phase 2-2) + - ✅ `subscribeToEvents()` - Real-time event notifications (Phase 2-2) -2. **Backend API Gaps** - - `joinGroup` mutation not yet in backend (returns mock data) - - Backend Phase 2-4 will add join functionality - - `dissolveGroup` is implemented and working +2. **Backend API Status** + - ✅ `createGroup` - Fully implemented and working + - ✅ `joinGroup` - Fully implemented and working + - ✅ `dissolveGroup` - Fully implemented with automatic member notification + - ✅ `reportDataByNode` - Fully implemented with group existence validation + - ✅ `fireEventByNode` - Fully implemented with group existence validation -3. **Display of Other Nodes** - - "Other Nodes Data" panel is placeholder - - Will populate when subscriptions are working +3. **Display Features** + - ✅ "Other Nodes Data" panel displays real-time sensor data from group members + - ✅ Event history shows received events from other nodes + - ✅ Group dissolution automatically clears UI and shows notification ### Future Enhancements -- [ ] Implement real WebSocket subscriptions -- [ ] Display other nodes' sensor data in real-time - [ ] Show group members list - [ ] Add reconnection logic for network failures - [ ] Persist group membership across page refresh diff --git a/examples/javascript-client/app.js b/examples/javascript-client/app.js index 0f1b8ff..a1580cd 100644 --- a/examples/javascript-client/app.js +++ b/examples/javascript-client/app.js @@ -16,6 +16,7 @@ const state = { sessionTimerId: null, dataSubscriptionId: null, eventSubscriptionId: null, + dissolveSubscriptionId: null, sensorData: { temperature: 20, brightness: 50, @@ -379,6 +380,13 @@ async function handleJoinGroup() { handleEventReceived ); + // Subscribe to group dissolution + state.dissolveSubscriptionId = state.client.subscribeToGroupDissolve( + state.currentGroup.id, + state.currentGroup.domain, + handleGroupDissolved + ); + showSuccess('groupSuccess', `Joined group: ${state.selectedGroup.name}`); updateCurrentGroupUI(); } catch (error) { @@ -692,6 +700,40 @@ function handleEventReceived(event) { showSuccess('eventSuccess', `Event received: ${event.name} from ${event.firedByNodeId}`); } +/** + * Handle group dissolution notification + */ +function handleGroupDissolved(dissolveData) { + console.log('Group has been dissolved:', dissolveData); + + // Unsubscribe from all active subscriptions + if (state.dataSubscriptionId) { + state.client.unsubscribe(state.dataSubscriptionId); + state.dataSubscriptionId = null; + } + + if (state.eventSubscriptionId) { + state.client.unsubscribe(state.eventSubscriptionId); + state.eventSubscriptionId = null; + } + + if (state.dissolveSubscriptionId) { + state.client.unsubscribe(state.dissolveSubscriptionId); + state.dissolveSubscriptionId = null; + } + + // Clear group state + state.currentGroup = null; + state.selectedGroupId = null; + + // Clear UI + displayOtherNodesData(null); + updateCurrentGroupUI(); + + // Show notification + showError('groupError', `Group has been dissolved: ${dissolveData.message}`); +} + /** * Display other nodes' sensor data */ diff --git a/js/functions/checkGroupExists.js b/js/functions/checkGroupExists.js new file mode 100644 index 0000000..e82614e --- /dev/null +++ b/js/functions/checkGroupExists.js @@ -0,0 +1,31 @@ +// checkGroupExists Pipeline Before Function +// グループの存在確認を行う共通Function +// reportDataByNode, fireEventByNode の前処理として使用 + +import { util } from '@aws-appsync/utils'; + +export function request(ctx) { + const { groupId, domain } = ctx.args; + + return { + operation: 'GetItem', + key: util.dynamodb.toMapValues({ + pk: `DOMAIN#${domain}`, + sk: `GROUP#${groupId}#METADATA` + }) + }; +} + +export function response(ctx) { + // グループが存在しない場合はエラー + if (!ctx.result) { + util.error( + `Group ${ctx.args.groupId}@${ctx.args.domain} does not exist or has been dissolved`, + 'GroupNotFound' + ); + } + + // グループ情報をcontextに保存(後続のfunctionで使用可能) + ctx.stash.group = ctx.result; + return ctx.result; +} diff --git a/js/resolvers/Mutation.joinGroup.js b/js/resolvers/Mutation.joinGroup.js index 662fa8d..a855ff9 100644 --- a/js/resolvers/Mutation.joinGroup.js +++ b/js/resolvers/Mutation.joinGroup.js @@ -10,6 +10,18 @@ export function request(ctx) { return { operation: 'TransactWriteItems', transactItems: [ + // 0. グループの存在確認 (ConditionCheck) + { + table: ctx.env.TABLE_NAME, + operation: 'ConditionCheck', + key: util.dynamodb.toMapValues({ + pk: `DOMAIN#${domain}`, + sk: `GROUP#${groupId}#METADATA` + }), + condition: { + expression: 'attribute_exists(pk)' + } + }, // 1. グループ内のノード情報を追加 { table: ctx.env.TABLE_NAME, diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index 1fea2af..a0323c2 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -168,23 +168,70 @@ export class MeshV2Stack extends cdk.Stack { // Resolvers for Phase 2-2: High-Frequency Mutations - // Mutation: reportDataByNode (DynamoDB integration) - dynamoDbDataSource.createResolver('ReportDataByNodeResolver', { + // Function: checkGroupExists (共通のグループ存在確認) + const checkGroupExistsFunction = new appsync.AppsyncFunction(this, 'CheckGroupExistsFunction', { + name: 'checkGroupExists', + api: this.api, + dataSource: dynamoDbDataSource, + runtime: appsync.FunctionRuntime.JS_1_0_0, + code: appsync.Code.fromAsset(path.join(__dirname, '../js/functions/checkGroupExists.js')) + }); + + // Function: reportDataByNode (main logic) + const reportDataByNodeFunction = new appsync.AppsyncFunction(this, 'ReportDataByNodeFunction', { + name: 'reportDataByNode', + api: this.api, + dataSource: dynamoDbDataSource, + runtime: appsync.FunctionRuntime.JS_1_0_0, + code: appsync.Code.fromAsset(path.join(__dirname, '../js/resolvers/Mutation.reportDataByNode.js')) + }); + + // Pipeline Resolver: reportDataByNode (グループ存在確認 → データ報告) + new appsync.Resolver(this, 'ReportDataByNodePipelineResolver', { + api: this.api, typeName: 'Mutation', fieldName: 'reportDataByNode', runtime: appsync.FunctionRuntime.JS_1_0_0, - code: appsync.Code.fromAsset(path.join(__dirname, '../js/resolvers/Mutation.reportDataByNode.js')) + pipelineConfig: [checkGroupExistsFunction, reportDataByNodeFunction], + code: appsync.Code.fromInline(` + // Pipeline resolver: pass through + export function request(ctx) { + return {}; + } + export function response(ctx) { + return ctx.prev.result; + } + `) }); // None Data Source for event pass-through const noneDataSource = this.api.addNoneDataSource('NoneDataSource'); - // Mutation: fireEventByNode (None DataSource for pass-through) - noneDataSource.createResolver('FireEventByNodeResolver', { + // Function: fireEventByNode (main logic) + const fireEventByNodeFunction = new appsync.AppsyncFunction(this, 'FireEventByNodeFunction', { + name: 'fireEventByNode', + api: this.api, + dataSource: noneDataSource, + runtime: appsync.FunctionRuntime.JS_1_0_0, + code: appsync.Code.fromAsset(path.join(__dirname, '../js/resolvers/Mutation.fireEventByNode.js')) + }); + + // Pipeline Resolver: fireEventByNode (グループ存在確認 → イベント発火) + new appsync.Resolver(this, 'FireEventByNodePipelineResolver', { + api: this.api, typeName: 'Mutation', fieldName: 'fireEventByNode', runtime: appsync.FunctionRuntime.JS_1_0_0, - code: appsync.Code.fromAsset(path.join(__dirname, '../js/resolvers/Mutation.fireEventByNode.js')) + pipelineConfig: [checkGroupExistsFunction, fireEventByNodeFunction], + code: appsync.Code.fromInline(` + // Pipeline resolver: pass through + export function request(ctx) { + return {}; + } + export function response(ctx) { + return ctx.prev.result; + } + `) }); // Resolvers for Phase 2-4: dissolveGroup with Lambda diff --git a/spec/requests/group_existence_validation_spec.rb b/spec/requests/group_existence_validation_spec.rb new file mode 100644 index 0000000..1cfd2af --- /dev/null +++ b/spec/requests/group_existence_validation_spec.rb @@ -0,0 +1,220 @@ +require "spec_helper" + +RSpec.describe "Group Existence Validation", type: :request do + let(:domain) { "test-validation-#{Time.now.to_i}.example.com" } + let(:host_id) { "host-validation-#{Time.now.to_i}" } + let(:node_id) { "node-validation-#{Time.now.to_i}" } + let(:group_name) { "Test Validation Group" } + + describe "joinGroup mutation" do + context "グループが存在しない場合" do + it "エラーを返す" do + query = File.read(File.join(__dir__, "../fixtures/mutations/join_group.graphql")) + response = execute_graphql(query, { + groupId: "non-existent-group", + domain: domain, + nodeId: node_id + }) + + # エラーレスポンス検証 + # joinGroup uses ConditionCheck which returns DynamoDB error message + expect(response["errors"]).not_to be_nil + expect(response["errors"][0]["message"]).to include("ConditionalCheckFailed") + end + end + + context "グループが削除された後" do + it "エラーを返す" do + # グループを作成 + group = create_test_group(group_name, host_id, domain) + group_id = group["id"] + + # グループを削除 + dissolve_query = File.read(File.join(__dir__, "../fixtures/mutations/dissolve_group.graphql")) + dissolve_response = execute_graphql(dissolve_query, { + groupId: group_id, + domain: domain, + hostId: host_id + }) + expect(dissolve_response["errors"]).to be_nil + + # 削除されたグループに参加を試みる + join_query = File.read(File.join(__dir__, "../fixtures/mutations/join_group.graphql")) + join_response = execute_graphql(join_query, { + groupId: group_id, + domain: domain, + nodeId: node_id + }) + + # エラーレスポンス検証 + # ConditionCheck failed returns DynamoDB error message + expect(join_response["errors"]).not_to be_nil + expect(join_response["errors"][0]["message"]).to include("ConditionalCheckFailed") + end + end + end + + describe "reportDataByNode mutation" do + context "グループが存在しない場合" do + it "エラーを返す" do + query = File.read(File.join(__dir__, "../fixtures/mutations/report_data_by_node.graphql")) + response = execute_graphql(query, { + groupId: "non-existent-group", + domain: domain, + nodeId: node_id, + data: [ + {key: "temperature", value: "25.5"}, + {key: "humidity", value: "60"} + ] + }) + + # エラーレスポンス検証 + # Pipeline resolver with checkGroupExists returns custom error message + expect(response["errors"]).not_to be_nil + expect(response["errors"][0]["message"]).to include("does not exist") + expect(response["errors"][0]["errorType"]).to eq("GroupNotFound") + end + end + + context "グループが削除された後" do + it "エラーを返す" do + # グループを作成してノードを参加させる + group = create_test_group(group_name, host_id, domain) + group_id = group["id"] + join_test_node(group_id, domain, node_id) + + # グループを削除 + dissolve_query = File.read(File.join(__dir__, "../fixtures/mutations/dissolve_group.graphql")) + dissolve_response = execute_graphql(dissolve_query, { + groupId: group_id, + domain: domain, + hostId: host_id + }) + expect(dissolve_response["errors"]).to be_nil + + # 削除されたグループにデータを報告を試みる + report_query = File.read(File.join(__dir__, "../fixtures/mutations/report_data_by_node.graphql")) + report_response = execute_graphql(report_query, { + groupId: group_id, + domain: domain, + nodeId: node_id, + data: [ + {key: "temperature", value: "25.5"} + ] + }) + + # エラーレスポンス検証 + expect(report_response["errors"]).not_to be_nil + expect(report_response["errors"][0]["message"]).to include("does not exist") + end + end + end + + describe "fireEventByNode mutation" do + context "グループが存在しない場合" do + it "エラーを返す" do + query = File.read(File.join(__dir__, "../fixtures/mutations/fire_event_by_node.graphql")) + response = execute_graphql(query, { + groupId: "non-existent-group", + domain: domain, + nodeId: node_id, + eventName: "test-event", + payload: "test payload" + }) + + # エラーレスポンス検証 + # Pipeline resolver with checkGroupExists returns custom error message + expect(response["errors"]).not_to be_nil + expect(response["errors"][0]["message"]).to include("does not exist") + expect(response["errors"][0]["errorType"]).to eq("GroupNotFound") + end + end + + context "グループが削除された後" do + it "エラーを返す" do + # グループを作成してノードを参加させる + group = create_test_group(group_name, host_id, domain) + group_id = group["id"] + join_test_node(group_id, domain, node_id) + + # グループを削除 + dissolve_query = File.read(File.join(__dir__, "../fixtures/mutations/dissolve_group.graphql")) + dissolve_response = execute_graphql(dissolve_query, { + groupId: group_id, + domain: domain, + hostId: host_id + }) + expect(dissolve_response["errors"]).to be_nil + + # 削除されたグループにイベント発火を試みる + fire_query = File.read(File.join(__dir__, "../fixtures/mutations/fire_event_by_node.graphql")) + fire_response = execute_graphql(fire_query, { + groupId: group_id, + domain: domain, + nodeId: node_id, + eventName: "test-event", + payload: "test payload" + }) + + # エラーレスポンス検証 + expect(fire_response["errors"]).not_to be_nil + expect(fire_response["errors"][0]["message"]).to include("does not exist") + end + end + end + + describe "E2E: dissolveGroup後のすべてのmutation検証" do + it "dissolveGroup後、すべてのグループ操作がエラーを返す" do + # グループを作成 + group = create_test_group(group_name, host_id, domain) + group_id = group["id"] + + # ノードを参加させる + join_test_node(group_id, domain, node_id) + + # グループを削除 + dissolve_query = File.read(File.join(__dir__, "../fixtures/mutations/dissolve_group.graphql")) + dissolve_response = execute_graphql(dissolve_query, { + groupId: group_id, + domain: domain, + hostId: host_id + }) + expect(dissolve_response["errors"]).to be_nil + expect(dissolve_response["data"]["dissolveGroup"]["message"]).to include("dissolved") + + # 新しいノードの参加を試みる(エラーになるべき) + new_node_id = "new-node-#{Time.now.to_i}" + join_query = File.read(File.join(__dir__, "../fixtures/mutations/join_group.graphql")) + join_response = execute_graphql(join_query, { + groupId: group_id, + domain: domain, + nodeId: new_node_id + }) + expect(join_response["errors"]).not_to be_nil + expect(join_response["errors"][0]["message"]).to include("ConditionalCheckFailed") + + # データ報告を試みる(エラーになるべき) + report_query = File.read(File.join(__dir__, "../fixtures/mutations/report_data_by_node.graphql")) + report_response = execute_graphql(report_query, { + groupId: group_id, + domain: domain, + nodeId: node_id, + data: [{key: "test", value: "value"}] + }) + expect(report_response["errors"]).not_to be_nil + expect(report_response["errors"][0]["message"]).to include("does not exist") + + # イベント発火を試みる(エラーになるべき) + fire_query = File.read(File.join(__dir__, "../fixtures/mutations/fire_event_by_node.graphql")) + fire_response = execute_graphql(fire_query, { + groupId: group_id, + domain: domain, + nodeId: node_id, + eventName: "test-event", + payload: nil + }) + expect(fire_response["errors"]).not_to be_nil + expect(fire_response["errors"][0]["message"]).to include("does not exist") + end + end +end