@@ -6,6 +6,7 @@ import path from "node:path";
66import {
77 createPluginStateKeyedStoreForTests ,
88 resetPluginStateStoreForTests ,
9+ setMaxPluginStateEntriesPerPluginForTests ,
910} from "openclaw/plugin-sdk/plugin-state-test-runtime" ;
1011import type {
1112 OpenKeyedStoreOptions ,
@@ -73,6 +74,7 @@ describe("msteams doctor state migration", () => {
7374 } ) ;
7475
7576 afterEach ( async ( ) => {
77+ setMaxPluginStateEntriesPerPluginForTests ( undefined ) ;
7678 await fs . rm ( stateDir , { recursive : true , force : true } ) ;
7779 } ) ;
7880
@@ -135,6 +137,123 @@ describe("msteams doctor state migration", () => {
135137 } ) ;
136138 } ) ;
137139
140+ it ( "retains legacy conversations after a production store failure and archives after retry" , async ( ) => {
141+ const filePath = path . join ( stateDir , "msteams-conversations.json" ) ;
142+ const conversationId = "19:[email protected] " ; 143+ const ref : StoredConversationReference = {
144+ conversation : { id : conversationId , conversationType : "personal" } ,
145+ channelId : "msteams" ,
146+ serviceUrl : "https://service.example.com" ,
147+ user : { id : "user-recover" } ,
148+ } ;
149+ await fs . writeFile (
150+ filePath ,
151+ `${ JSON . stringify ( {
152+ version : 1 ,
153+ conversations : {
154+ "legacy-user-key" : ref ,
155+ } ,
156+ } satisfies MSTeamsLegacyConversationStoreData ) } \n`,
157+ ) ;
158+
159+ const migration = migrationById ( "msteams-conversations-json-to-plugin-state" ) ;
160+ const context = createDoctorContext ( env ) ;
161+ const siblingStore = context . openPluginStateKeyedStore < { value : string } > ( {
162+ namespace : "conversation-migration-fixture" ,
163+ maxEntries : 10 ,
164+ } ) ;
165+ await siblingStore . register ( "occupied" , { value : "already-here" } ) ;
166+
167+ setMaxPluginStateEntriesPerPluginForTests ( 1 ) ;
168+ await expect (
169+ migration . migrateLegacyState ( {
170+ config : { } ,
171+ env,
172+ stateDir,
173+ oauthDir : path . join ( stateDir , "oauth" ) ,
174+ context,
175+ } ) ,
176+ ) . rejects . toMatchObject ( { code : "PLUGIN_STATE_LIMIT_EXCEEDED" } ) ;
177+
178+ await expect ( fs . access ( filePath ) ) . resolves . toBeUndefined ( ) ;
179+ await expect ( fs . access ( `${ filePath } .migrated` ) ) . rejects . toThrow ( ) ;
180+
181+ setMaxPluginStateEntriesPerPluginForTests ( undefined ) ;
182+ resetPluginStateStoreForTests ( { closeDatabase : false } ) ;
183+ const result = await migration . migrateLegacyState ( {
184+ config : { } ,
185+ env,
186+ stateDir,
187+ oauthDir : path . join ( stateDir , "oauth" ) ,
188+ context,
189+ } ) ;
190+
191+ expect ( result . warnings ) . toEqual ( [ ] ) ;
192+ expect ( result . changes ) . toEqual ( [
193+ expect . stringContaining ( "Migrated 1 Microsoft Teams conversation entry" ) ,
194+ expect . stringContaining ( "Archived Microsoft Teams conversation legacy source" ) ,
195+ ] ) ;
196+ await expect ( fs . access ( filePath ) ) . rejects . toThrow ( ) ;
197+ await expect ( fs . access ( `${ filePath } .migrated` ) ) . resolves . toBeUndefined ( ) ;
198+
199+ const store = context . openPluginStateKeyedStore < StoredConversationReference > ( {
200+ namespace : MSTEAMS_CONVERSATIONS_NAMESPACE ,
201+ maxEntries : 2000 ,
202+ } ) ;
203+ await expect ( store . lookup ( buildMSTeamsConversationStateKey ( conversationId ) ) ) . resolves . toEqual (
204+ expect . objectContaining ( {
205+ conversation : expect . objectContaining ( { id : conversationId } ) ,
206+ user : expect . objectContaining ( { id : "user-recover" } ) ,
207+ } ) ,
208+ ) ;
209+ await expect ( store . lookup ( buildMSTeamsConversationStateKey ( "legacy-user-key" ) ) ) . resolves . toBe (
210+ undefined ,
211+ ) ;
212+ } ) ;
213+
214+ it ( "archives legacy conversations when zero imports are already present in plugin state" , async ( ) => {
215+ const filePath = path . join ( stateDir , "msteams-conversations.json" ) ;
216+ const ref : StoredConversationReference = {
217+ conversation :
{ id :
"19:[email protected] " } , 218+ channelId : "msteams" ,
219+ serviceUrl : "https://service.example.com" ,
220+ user : { id : "user-existing" } ,
221+ } ;
222+ await fs . writeFile (
223+ filePath ,
224+ `${ JSON . stringify ( {
225+ version : 1 ,
226+ conversations : {
227+ 228+ } ,
229+ } satisfies MSTeamsLegacyConversationStoreData ) } \n`,
230+ ) ;
231+
232+ const migration = migrationById ( "msteams-conversations-json-to-plugin-state" ) ;
233+ const context = createDoctorContext ( env ) ;
234+ const store = context . openPluginStateKeyedStore < StoredConversationReference > ( {
235+ namespace : MSTEAMS_CONVERSATIONS_NAMESPACE ,
236+ maxEntries : 2000 ,
237+ } ) ;
238+ await store . register ( buildMSTeamsConversationStateKey ( "19:[email protected] " ) , ref ) ; 239+
240+ const result = await migration . migrateLegacyState ( {
241+ config : { } ,
242+ env,
243+ stateDir,
244+ oauthDir : path . join ( stateDir , "oauth" ) ,
245+ context,
246+ } ) ;
247+
248+ expect ( result . warnings ) . toEqual ( [ ] ) ;
249+ expect ( result . changes ) . toEqual ( [
250+ expect . stringContaining ( "Migrated 0 Microsoft Teams conversation entries" ) ,
251+ expect . stringContaining ( "Archived Microsoft Teams conversation legacy source" ) ,
252+ ] ) ;
253+ await expect ( fs . access ( filePath ) ) . rejects . toThrow ( ) ;
254+ await expect ( fs . access ( `${ filePath } .migrated` ) ) . resolves . toBeUndefined ( ) ;
255+ } ) ;
256+
138257 it ( "imports legacy polls and vote buckets into plugin state" , async ( ) => {
139258 const filePath = path . join ( stateDir , "msteams-polls.json" ) ;
140259 const poll : MSTeamsPoll = {
0 commit comments