// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package spellcheck import ( "context" "encoding/json" "errors" "io" "os" "os/exec" "path/filepath" "reflect" "syscall" "testing" "time" ) func TestExceptionEditsPreserveOtherReadersChanges(t *testing.T) { path := filepath.Join(t.TempDir(), "exceptions.json") options := Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }} first, second := New_service(context.Background(), options), New_service(context.Background(), options) defer first.Close() defer second.Close() // Both readers load the original list, then the user switches between them. receive_result(t, first, Request{Operation: Inspect}) receive_result(t, second, Request{Operation: Inspect}) for _, edit := range []struct { service *Service request Request want []string }{ {first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha"}}, {second, Request{Operation: Add, Word: "Beta"}, []string{"Alpha", "Beta"}}, {first, Request{Operation: Remove, Word: "ALPHA"}, []string{"Beta"}}, {second, Request{Operation: Add, Word: "Gamma"}, []string{"Beta", "Gamma"}}, {first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha", "Beta", "Gamma"}}, } { result := receive_result(t, edit.service, edit.request) if result.Err != nil || !reflect.DeepEqual(result.Exceptions, edit.want) { t.Fatalf("edit %v: result=%+v, want=%v", edit.request, result, edit.want) } words, err := load_exceptions(path) if err != nil || !reflect.DeepEqual(exception_list(words), edit.want) { t.Fatalf("saved exceptions=%v, error=%v, want=%v", words, err, edit.want) } } } // Separate processes verify that the transaction lock works across readers. func TestExceptionWriterProcess(t *testing.T) { path := os.Getenv("FENRIR_EXCEPTION_TEST_PATH") if path == "" { return } service := New_service(context.Background(), Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }}) defer service.Close() encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin) result := receive_result(t, service, Request{Operation: Inspect}) if result.Err != nil { t.Fatal(result.Err) } if err := encoder.Encode(result.Exceptions); err != nil { t.Fatal(err) } for { var request Request if err := decoder.Decode(&request); err != nil { if errors.Is(err, io.EOF) { return } t.Fatal(err) } result := receive_result(t, service, request) if result.Err != nil { t.Fatal(result.Err) } if err := encoder.Encode(result.Exceptions); err != nil { t.Fatal(err) } } } func TestConcurrentExceptionProcessesKeepBothEdits(t *testing.T) { path := filepath.Join(t.TempDir(), "exceptions.json") type Writer struct { encoder *json.Encoder decoder *json.Decoder } start_writer := func() Writer { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExceptionWriterProcess$") cmd.Env = append(os.Environ(), "FENRIR_EXCEPTION_TEST_PATH="+path) cmd.Stderr = os.Stderr input, err := cmd.StdinPipe() if err != nil { cancel() t.Fatal(err) } output, err := cmd.StdoutPipe() if err != nil { cancel() t.Fatal(err) } if err := cmd.Start(); err != nil { cancel() t.Fatal(err) } t.Cleanup(func() { input.Close() err := cmd.Wait() cancel() if err != nil { t.Error(err) } }) writer := Writer{json.NewEncoder(input), json.NewDecoder(output)} var ready []string if err := writer.decoder.Decode(&ready); err != nil { t.Fatal(err) } return writer } first, second := start_writer(), start_writer() for _, edit := range []struct { writer Writer word string }{{first, "Alpha"}, {second, "Beta"}} { if err := edit.writer.encoder.Encode(Request{Operation: Add, Word: edit.word}); err != nil { t.Fatal(err) } } for _, writer := range []Writer{first, second} { var words []string if err := writer.decoder.Decode(&words); err != nil { t.Fatal(err) } } words, err := load_exceptions(path) if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha", "Beta"}) { t.Fatal("lost concurrent addition", words, err) } info, err := os.Stat(path + ".lock") if err != nil || info.Mode().Perm() != 0600 { t.Fatal("lock permissions", info, err) } } func TestExceptionLockCancellationAndInvalidStorePreserveData(t *testing.T) { path := filepath.Join(t.TempDir(), "exceptions.json") if err := save_exceptions(path, map[string]string{"alpha": "Alpha"}); err != nil { t.Fatal(err) } lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600) if err != nil { t.Fatal(err) } defer lock.Close() if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() if _, err := update_exceptions(ctx, path, Add, "Beta"); !errors.Is(err, context.DeadlineExceeded) { t.Fatal("lock wait ignored cancellation", err) } words, err := load_exceptions(path) if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha"}) { t.Fatal("cancelled edit changed data", words, err) } if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil { t.Fatal(err) } if _, err := update_exceptions(context.Background(), path, Add, "Beta"); err != nil { t.Fatal("edit failed after lock release", err) } if err := os.WriteFile(path, []byte("invalid JSON"), 0600); err != nil { t.Fatal(err) } if _, err := update_exceptions(context.Background(), path, Add, "Gamma"); err == nil { t.Fatal("edit overwrote invalid shared file") } data, err := os.ReadFile(path) if err != nil || string(data) != "invalid JSON" { t.Fatal("invalid file was replaced", string(data), err) } }