@@ -18,7 +18,9 @@ limitations under the License.
1818package e2e
1919
2020import (
21+ "encoding/json"
2122 "testing"
23+ "time"
2224
2325 "github.com/apache/devlake/core/models/common"
2426 "github.com/apache/devlake/core/models/domainlayer/ticket"
@@ -130,6 +132,120 @@ func TestYoutrackIssueDataFlowWithMappings(t *testing.T) {
130132 assert .Zero (t , storyAsRequirement , "no row keeps the stale mapping" )
131133}
132134
135+ // TestYoutrackMappingReplayPreservesState covers the transition the
136+ // DataFlowTester.Subtask reruns above cannot: Subtask deletes subtask state
137+ // and forces FullSync, so it can never verify that a config change
138+ // invalidates PERSISTED incremental state. This scenario keeps the state:
139+ //
140+ // run 1 initial extraction (full sync: no prior state)
141+ // run 2 incremental update — one issue re-collected without its tag
142+ // run 3 mapping change — full re-extraction replayed from the raw layer
143+ //
144+ // asserting (a) the incremental run processes only the new raw version,
145+ // (b) the replay never re-hits the API and never resurrects the removed
146+ // tag from the retained old version, and (c) the new mapping applies.
147+ func TestYoutrackMappingReplayPreservesState (t * testing.T ) {
148+ var youtrack impl.Youtrack
149+ dataflowTester := e2ehelper .NewDataFlowTester (t , "youtrack" , youtrack )
150+ beginStatefulScenario (t , dataflowTester )
151+
152+ dataflowTester .ImportCsvIntoRawTable ("./raw_tables/_raw_youtrack_issues.csv" , "_raw_youtrack_issues" )
153+ dataflowTester .FlushTabler (& models.YoutrackIssue {})
154+ dataflowTester .FlushTabler (& models.YoutrackIssueLabel {})
155+ dataflowTester .FlushTabler (& models.YoutrackAccount {})
156+
157+ // run 1: initial full extraction
158+ for _ , projectId := range []string {"0-1" , "0-2" } {
159+ runSubtaskPreservingState (t , dataflowTester , tasks .ExtractIssuesMeta , newTaskData (projectId , mappedConfig ()))
160+ }
161+
162+ // the fixture's tagged issue in project 0-1 (PROJ1-1, tag "Долго в работе")
163+ const taggedIssue = "2-1"
164+ countLabels := func (issueId string ) int64 {
165+ var n int64
166+ require .NoError (t , dataflowTester .Db .Model (& models.YoutrackIssueLabel {}).
167+ Where ("connection_id = 1 AND issue_id = ?" , issueId ).Count (& n ).Error )
168+ return n
169+ }
170+ var totalLabels int64
171+ require .NoError (t , dataflowTester .Db .Model (& models.YoutrackIssueLabel {}).
172+ Where ("connection_id = 1" ).Count (& totalLabels ).Error )
173+ require .Equal (t , int64 (1 ), countLabels (taggedIssue ), "run 1 extracts the fixture's tag" )
174+ require .Positive (t , totalLabels )
175+
176+ // the incremental update: the same issue re-collected tagless, updated later
177+ var rawCountBefore int64
178+ require .NoError (t , dataflowTester .Db .Table ("_raw_youtrack_issues" ).Count (& rawCountBefore ).Error )
179+ type rawIssueRow struct {
180+ Params string
181+ Data []byte
182+ Url string
183+ }
184+ var rows []rawIssueRow
185+ require .NoError (t , dataflowTester .Db .Table ("_raw_youtrack_issues" ).
186+ Where ("params LIKE ?" , `%0-1%` ).Find (& rows ).Error )
187+ var src rawIssueRow
188+ var v2data []byte
189+ for _ , row := range rows {
190+ var issue map [string ]interface {}
191+ require .NoError (t , json .Unmarshal (row .Data , & issue ))
192+ if issue ["id" ] != taggedIssue {
193+ continue
194+ }
195+ issue ["tags" ] = []interface {}{}
196+ issue ["updated" ] = issue ["updated" ].(float64 ) + 60000
197+ v2 , err := json .Marshal (issue )
198+ require .NoError (t , err )
199+ v2data = v2
200+ src = row
201+ break
202+ }
203+ require .NotNil (t , v2data , "the fixture must contain issue %s in project 0-1" , taggedIssue )
204+ require .NoError (t , dataflowTester .Db .Table ("_raw_youtrack_issues" ).Create (map [string ]interface {}{
205+ "params" : src .Params ,
206+ "data" : v2data ,
207+ "url" : src .Url ,
208+ "created_at" : time .Now (), // inside run 2's incremental window
209+ }).Error )
210+
211+ // run 2: same config, state preserved -> incremental — only the new
212+ // version is processed, the tag disappears
213+ for _ , projectId := range []string {"0-1" , "0-2" } {
214+ runSubtaskPreservingState (t , dataflowTester , tasks .ExtractIssuesMeta , newTaskData (projectId , mappedConfig ()))
215+ }
216+ assert .Zero (t , countLabels (taggedIssue ), "incremental re-extraction replaces the issue's labels" )
217+ assert .Equal (t , totalLabels - 1 , func () int64 {
218+ var n int64
219+ require .NoError (t , dataflowTester .Db .Model (& models.YoutrackIssueLabel {}).
220+ Where ("connection_id = 1" ).Count (& n ).Error )
221+ return n
222+ }(), "every other issue's labels are untouched" )
223+
224+ // run 3: a mapping change must invalidate the persisted incremental
225+ // state -> full re-extraction replayed from the raw layer. The retained
226+ // v1 still carries the tag; only latest-version expansion keeps it dead.
227+ remapped := mappedConfig ()
228+ remapped .TypeMappings = map [string ]string {"Story" : ticket .TASK , "Bug" : ticket .BUG }
229+ for _ , projectId := range []string {"0-1" , "0-2" } {
230+ runSubtaskPreservingState (t , dataflowTester , tasks .ExtractIssuesMeta , newTaskData (projectId , remapped ))
231+ }
232+ assert .Zero (t , countLabels (taggedIssue ),
233+ "mapping replay must not resurrect the tag from the retained old raw version" )
234+
235+ var storyAsTask , storyAsRequirement int64
236+ require .NoError (t , dataflowTester .Db .Model (& models.YoutrackIssue {}).
237+ Where ("connection_id = 1 AND type_name = 'Story' AND std_type = ?" , ticket .TASK ).Count (& storyAsTask ).Error )
238+ require .NoError (t , dataflowTester .Db .Model (& models.YoutrackIssue {}).
239+ Where ("connection_id = 1 AND type_name = 'Story' AND std_type = ?" , ticket .REQUIREMENT ).Count (& storyAsRequirement ).Error )
240+ assert .Positive (t , storyAsTask , "the changed mapping re-applies from the raw layer" )
241+ assert .Zero (t , storyAsRequirement , "no row keeps the stale mapping" )
242+
243+ var rawCountAfter int64
244+ require .NoError (t , dataflowTester .Db .Table ("_raw_youtrack_issues" ).Count (& rawCountAfter ).Error )
245+ assert .Equal (t , rawCountBefore + 1 , rawCountAfter ,
246+ "the replay read the raw layer only — no collection call (the task data carries no API client)" )
247+ }
248+
133249// TestYoutrackIssueDataFlowMissingField covers the renamed-field trap: a
134250// configured-but-absent field warns per scope and leaves the dedicated
135251// columns empty; the run completes (warn, never fail).
0 commit comments